diff --git a/docs/catalogue-updates.md b/docs/catalogue-updates.md index 7b525ab..b84f51e 100644 --- a/docs/catalogue-updates.md +++ b/docs/catalogue-updates.md @@ -31,7 +31,15 @@ 启动会核对清单、大小、SHA-256、JSON 契约和 ZIP 图片内容,成功后才原子切换 数据目录下的 `catalogue/current.json`。断网、404 或版本无效时优先使用已经 -验证的缓存,再使用插件基线。日志位于同一数据目录的 `farm-preview.log`。 +验证的缓存,再使用插件基线。真寻和NoneBot日志位于同一数据目录的 +`farm-preview.log`;AstrBot统一使用宿主日志,在AstrBot控制台或日志页查看, +插件不创建独立日志文件,也不修改宿主日志级别或处理器。 + +终端同时显示启动阶段、缓存复用、下载开始、进度、完成及回退信息。下载进度 +最多每秒输出一次;服务器提供文件总大小时显示百分比及 MiB,未提供时只显示 +已下载量。文件下载完成后仍需校验,只有整套校验通过才切换版本。插件优先 +复用宿主已有的 INFO 终端日志通道;真寻和NoneBot没有时添加一个并在关闭时 +移除。AstrBot始终使用 `astrbot.api.logger`,输出级别和位置由宿主管理。 备份应包含完整数据目录及其 `catalogue` 缓存。玩家库存或地块若引用基线没有的 新作物 ID,而可用缓存和远端均不能提供这些 ID,插件会明确拒绝启动,避免 diff --git a/integrations/astrbot/CHANGELOG.md b/integrations/astrbot/CHANGELOG.md index 551d3d4..3453ab4 100644 --- a/integrations/astrbot/CHANGELOG.md +++ b/integrations/astrbot/CHANGELOG.md @@ -1,5 +1,11 @@ # 更新日志 +## 待发布 + +- 全部插件及私有共享模块日志接入AstrBot官方logger,保留启动下载进度。 +- 不再配置独立日志文件或修改宿主日志处理器;移除包内开发命令行入口。 +- 新增发布包日志规范检查,拒绝内置logging及自建日志处理器。 + ## 2.0.2 — 待发布 ### 新增 diff --git a/integrations/astrbot/avatars.py b/integrations/astrbot/avatars.py index b3a5b8c..ba5d477 100644 --- a/integrations/astrbot/avatars.py +++ b/integrations/astrbot/avatars.py @@ -2,14 +2,13 @@ import asyncio from collections import OrderedDict -import logging import re from time import monotonic import aiohttp +from astrbot.api import logger -logger = logging.getLogger(__name__) MAX_BYTES = 2 * 1024 * 1024 MAX_ENTRIES = 16 FETCH_TIMEOUT = 2 diff --git a/integrations/astrbot/main.py b/integrations/astrbot/main.py index 6fa2cbe..96a485c 100644 --- a/integrations/astrbot/main.py +++ b/integrations/astrbot/main.py @@ -2,10 +2,10 @@ import asyncio import json -import logging from pathlib import Path from astrbot.api import AstrBotConfig +from astrbot.api import logger from astrbot.api.event import AstrMessageEvent, filter from astrbot.api.message_components import At, Image from astrbot.api.star import Context, Star, StarTools @@ -16,9 +16,6 @@ from .runtime import Runtime, command_map COMMANDS = command_map('astrbot') -logger = logging.getLogger(__name__) - - class FarmPlugin(Star): def __init__(self, context: Context, config: AstrBotConfig): super().__init__(context) diff --git a/integrations/astrbot/runtime_logging.py b/integrations/astrbot/runtime_logging.py new file mode 100644 index 0000000..ea2eef7 --- /dev/null +++ b/integrations/astrbot/runtime_logging.py @@ -0,0 +1,9 @@ +"""AstrBot拥有日志配置和输出,插件不创建或关闭任何日志处理器。""" + + +def open_logs(data_dir): + return None, None + + +def close_logs(file_handler, console_handler): + pass diff --git a/integrations/shared/runtime.py b/integrations/shared/runtime.py index 127856c..281cc27 100644 --- a/integrations/shared/runtime.py +++ b/integrations/shared/runtime.py @@ -10,6 +10,7 @@ import shlex from urllib.error import HTTPError, URLError from zipfile import BadZipFile +from .runtime_logging import open_logs, close_logs from ._vendor.farm_core import Catalogue, FarmService, Identity, SQLiteStore from ._vendor.farm_core.domain import FarmError from ._vendor.farm_core.commands import ( @@ -133,6 +134,7 @@ class Runtime: self.rendering = None self.started = False self.log_handler = None + self.console_handler = None self.legacy_source = self.data_dir / 'farm.db' self._resource_lock = asyncio.Lock() self._closing = False @@ -153,15 +155,9 @@ class Runtime: return epoch = self._start_epoch self.data_dir.mkdir(parents=True, exist_ok=True) - self.log_handler = logging.FileHandler( - self.data_dir / "farm-preview.log", encoding="utf-8", - ) - self.log_handler.setFormatter(logging.Formatter( - "%(asctime)s %(levelname)s %(name)s %(message)s", - )) - logging.getLogger(__package__).setLevel(logging.INFO) - logging.getLogger(__package__).addHandler(self.log_handler) + self.log_handler, self.console_handler = open_logs(self.data_dir) try: + logger.info('农场启动:正在检查作物版本和本地缓存') if self.resource_options_provider is not None: self.resource_source, self.service_url = ( self.resource_options_provider() @@ -191,6 +187,8 @@ class Runtime: if self._closing or epoch != self._start_epoch: return self.started = True + logger.info('农场启动完成(%s)', + '图片可用' if self.rendering else '文字模式') logger.info("farm preview initialized schema=%s", SCHEMA_VERSION) async def _prepare_resources(self): @@ -206,6 +204,7 @@ class Runtime: ) validate_options(self.resource_source, self.service_url) resource = 'assets' + logger.info('农场启动:正在准备图片素材和字体') selected = self.catalogue_release bundle_path = selected.bundle_path if selected else None if bundle_path is None: @@ -265,10 +264,8 @@ class Runtime: await self.store.close() self.started = False self._closing = False - if self.log_handler: - logging.getLogger(__package__).removeHandler(self.log_handler) - self.log_handler.close() - self.log_handler = None + close_logs(self.log_handler, self.console_handler) + self.log_handler = self.console_handler = None async def execute( self, action, arguments, *, bot_id, platform, user_id, message_id, diff --git a/integrations/shared/runtime_logging.py b/integrations/shared/runtime_logging.py new file mode 100644 index 0000000..7303cd3 --- /dev/null +++ b/integrations/shared/runtime_logging.py @@ -0,0 +1,43 @@ +"""真寻和NoneBot的日志生命周期;AstrBot构建使用宿主专用实现。""" + +import logging + + +def has_console_handler(target): + while target is not None: + if any(isinstance(handler, logging.StreamHandler) + and not isinstance(handler, logging.FileHandler) + and handler.level <= logging.INFO + for handler in target.handlers): + return True + target = target.parent if target.propagate else None + return False + + +def open_logs(data_dir): + target = logging.getLogger(__package__) + file_handler = logging.FileHandler( + data_dir / 'farm-preview.log', encoding='utf-8', + ) + file_handler.setFormatter(logging.Formatter( + '%(asctime)s %(levelname)s %(name)s %(message)s', + )) + target.setLevel(logging.INFO) + target.addHandler(file_handler) + console_handler = None + if not has_console_handler(target): + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + console_handler.setFormatter(logging.Formatter( + '%(asctime)s [农场] %(message)s', datefmt='%H:%M:%S', + )) + target.addHandler(console_handler) + return file_handler, console_handler + + +def close_logs(file_handler, console_handler): + target = logging.getLogger(__package__) + for handler in (file_handler, console_handler): + if handler is not None: + target.removeHandler(handler) + handler.close() diff --git a/scripts/build_astrbot.py b/scripts/build_astrbot.py index ae778c4..8ea73c0 100644 --- a/scripts/build_astrbot.py +++ b/scripts/build_astrbot.py @@ -15,6 +15,33 @@ from verify_zhenxun import read_package as read_shared from help_image import build_help_image +def host_logging(content): + """仅替换日志入口,保留共享业务源码和注释;未知用法交给包校验拒绝。""" + lines = content.decode('utf-8').splitlines(keepends=True) + edits = [] + for node in ast.parse(content).body: + if (isinstance(node, ast.Import) + and any(alias.name == 'logging' for alias in node.names)): + if len(node.names) != 1 or node.names[0].asname: + raise ValueError('Unsupported logging import') + edits.append((node.lineno, node.end_lineno, '')) + if (isinstance(node, ast.Assign) and len(node.targets) == 1 + and isinstance(node.targets[0], ast.Name) + and isinstance(node.value, ast.Call) + and isinstance(node.value.func, ast.Attribute) + and isinstance(node.value.func.value, ast.Name) + and node.value.func.value.id == 'logging' + and node.value.func.attr == 'getLogger'): + alias = node.targets[0].id + statement = 'from astrbot.api import logger' + if alias != 'logger': + statement += ' as ' + alias + edits.append((node.lineno, node.end_lineno, statement + '\n')) + for start, end, replacement in sorted(edits, reverse=True): + lines[start-1:end] = [replacement] + return ''.join(lines).encode('utf-8') + + def render_readme(root): template = (root / 'readme/astrbot.md').read_text(encoding='utf-8') marker = '' @@ -49,7 +76,7 @@ def build(root, output, *, help_image=None): files = {name: content for name, content in shared.items() if name.startswith('_vendor/') or name in ( '_version.py', 'version.txt', 'LICENSE', - )} + ) if not name.endswith('/__main__.py')} source = root / 'integrations/astrbot' for name in ('main.py', 'avatars.py', '_conf_schema.json', 'requirements.txt', 'CHANGELOG.md'): @@ -65,6 +92,9 @@ def build(root, output, *, help_image=None): files['runtime.py'] = ( root / 'integrations/shared/runtime.py' ).read_bytes() + files['runtime_logging.py'] = ( + source / 'runtime_logging.py' + ).read_bytes() files['verify_package.py'] = ( root / 'scripts/verify_astrbot.py' ).read_bytes() @@ -82,6 +112,8 @@ def build(root, output, *, help_image=None): }, ensure_ascii=False, indent=2) + '\n').encode() for name, content in files.items(): if name.endswith('.py'): + content = host_logging(content) + files[name] = content ast.parse(content, filename=name, feature_version=(3, 10)) manifest = { 'schema': 1, 'target': 'astrbot', 'plugin': ROOT_NAME, diff --git a/scripts/build_nonebot.py b/scripts/build_nonebot.py index 052d66d..990f93b 100644 --- a/scripts/build_nonebot.py +++ b/scripts/build_nonebot.py @@ -32,6 +32,9 @@ def build(root, output, *, help_image=None): files[prefix + "runtime.py"] = ( root / "integrations/shared/runtime.py" ).read_bytes() + files[prefix + 'runtime_logging.py'] = ( + root / 'integrations/shared/runtime_logging.py' + ).read_bytes() files[prefix + "_vendor/__init__.py"] = b'"""Private farm modules."""\n' for package in ("farm_core", "farm_render"): source = root / "src" / package diff --git a/scripts/build_zhenxun.py b/scripts/build_zhenxun.py index 2504408..0931511 100644 --- a/scripts/build_zhenxun.py +++ b/scripts/build_zhenxun.py @@ -57,6 +57,9 @@ def build(root, output, *, help_image=None): files["runtime.py"] = ( root / "integrations/shared/runtime.py" ).read_bytes() + files['runtime_logging.py'] = ( + root / 'integrations/shared/runtime_logging.py' + ).read_bytes() files["_vendor/__init__.py"] = b'"""Private shared farm modules."""\n' for package in ("farm_core", "farm_render"): source = root / "src" / package diff --git a/scripts/verify_astrbot.py b/scripts/verify_astrbot.py index cab9c1d..09da9a3 100644 --- a/scripts/verify_astrbot.py +++ b/scripts/verify_astrbot.py @@ -1,6 +1,7 @@ """AstrBot插件ZIP的独立完整性校验;只使用标准库。""" import argparse +import ast import hashlib import json from pathlib import Path, PurePosixPath @@ -37,8 +38,39 @@ def verify_files(files): safe_path(name) if hashlib.sha256(files[name]).hexdigest() != digest: raise ValueError('Package checksum mismatch') + if name.endswith('/__main__.py'): + raise ValueError('Development CLI must not be in AstrBot plugin') + if name.endswith('.py'): + tree = ast.parse(files[name], filename=name) + host_loggers = { + alias.asname or alias.name + for node in ast.walk(tree) + if isinstance(node, ast.ImportFrom) + and node.module == 'astrbot.api' + for alias in node.names if alias.name == 'logger' + } + for node in ast.walk(tree): + if (isinstance(node, ast.Import) and any( + alias.name.split('.')[0] == 'logging' + for alias in node.names)) or ( + isinstance(node, ast.ImportFrom) + and (node.module or '').split('.')[0] == 'logging'): + raise ValueError('AstrBot plugin must not import logging') + if isinstance(node, ast.Attribute) and node.attr in { + 'basicConfig', 'FileHandler', 'StreamHandler', + 'getLogger', 'setLevel', 'addHandler', 'removeHandler'}: + raise ValueError('AstrBot must own logging configuration') + if (isinstance(node, ast.Call) + and isinstance(node.func, ast.Attribute) + and isinstance(node.func.value, ast.Name) + and node.func.value.id in {'logger', 'LOGGER'} + and node.func.value.id not in host_loggers): + raise ValueError( + 'AstrBot logger must come from astrbot.api', + ) required = {'main.py', 'avatars.py', 'metadata.yaml', '_conf_schema.json', - 'runtime.py', 'README.md', 'CHANGELOG.md', 'logo.png', + 'runtime.py', 'runtime_logging.py', 'README.md', + 'CHANGELOG.md', 'logo.png', 'logo-readme.png', 'images/farm-preview.png', 'requirements.txt', 'LICENSE', 'version.txt', '_version.py', diff --git a/src/farm_core/storage.py b/src/farm_core/storage.py index 39104b2..2b38f45 100644 --- a/src/farm_core/storage.py +++ b/src/farm_core/storage.py @@ -434,9 +434,9 @@ class SQLiteStore: connection.commit() except BaseException as exc: connection.rollback() - LOGGER.log( - logging.INFO if isinstance(exc, FarmError) - else logging.WARNING, + report = (LOGGER.info if isinstance(exc, FarmError) + else LOGGER.warning) + report( "farm rollback action=%s operation=%s error=%s", action, operation_id, type(exc).__name__, ) diff --git a/src/farm_render/bundle.py b/src/farm_render/bundle.py index ace4880..42455e5 100644 --- a/src/farm_render/bundle.py +++ b/src/farm_render/bundle.py @@ -43,21 +43,48 @@ def checked_url(url): def _copy_remote(url, destination, limit): request = Request(checked_url(url), headers={'User-Agent': 'Farm-assets/1'}) + parts = urlsplit(url).path.split('/') + name = { + 'manifest.json': '作物版本清单', 'plants.json': '作物列表', + 'metadata.json': '展示配置', 'land-soils.zip': '土地素材', + 'assets.zip': '字体包' if 'fonts' in parts else '图片素材包', + }.get(parts[-1], '资源') started = time.monotonic() - with urlopen(request, timeout=15) as response: - checked_url(response.geturl()) - length = response.headers.get('Content-Length') - if length is not None and int(length) > limit: - raise ValueError('Resource download exceeds limit') - total = 0 - while True: - chunk = response.read(min(65536, limit-total+1)) - if not chunk: - break - total += len(chunk) - if total > limit or time.monotonic()-started > 60: - raise ValueError('Resource download limit or timeout') - destination.write(chunk) + last_report = started + logger.info('农场下载开始:%s', name) + try: + with urlopen(request, timeout=15) as response: + checked_url(response.geturl()) + header = response.headers.get('Content-Length') + length = int(header) if header is not None else 0 + if length > limit: + raise ValueError('Resource download exceeds limit') + total = 0 + while True: + chunk = response.read(min(65536, limit-total+1)) + if not chunk: + break + total += len(chunk) + now = time.monotonic() + if total > limit or now-started > 60: + raise ValueError('Resource download limit or timeout') + destination.write(chunk) + if now-last_report >= 1: + if length > 0: + logger.info( + '农场下载进度:%s %.1f%%(%.2f/%.2f MiB)', + name, min(100, total * 100 / length), + total / 1048576, length / 1048576, + ) + else: + logger.info('农场下载进度:%s 已下载 %.2f MiB', + name, total / 1048576) + last_report = now + logger.info('农场下载完成:%s 共 %.2f MiB(待校验)', + name, total / 1048576) + except Exception as error: + logger.warning('农场下载失败:%s(%s)', name, type(error).__name__) + raise def verify_bundle(path, manifest): @@ -93,7 +120,9 @@ def ensure_assets(data_dir, source='Github', service_url=''): target = (Path(data_dir) / 'assets' / expected['sha256'] / 'assets.zip') with _LOCK: try: - return verify_bundle(target, expected) + cached = verify_bundle(target, expected) + logger.info('农场图片素材:复用已校验缓存') + return cached except (OSError, ValueError): pass target.parent.mkdir(parents=True, exist_ok=True) diff --git a/src/farm_render/catalogue_bundle.py b/src/farm_render/catalogue_bundle.py index 49f85b8..0f63f86 100644 --- a/src/farm_render/catalogue_bundle.py +++ b/src/farm_render/catalogue_bundle.py @@ -140,6 +140,7 @@ def load_catalogue_release(data_dir, source='Github', service_url='', if not set(required_ids).issubset( plant.plant_id for plant in current.catalogue): raise ValueError('catalogue missing player crop ID') + logger.info('农场作物版本未变化:复用已校验缓存') return replace(current, origin='remote') try: candidate = _snapshot(target, manifest, previous, required_ids, @@ -174,6 +175,7 @@ def load_catalogue_release(data_dir, source='Github', service_url='', previous_images = (current or bootstrap).metadata['images'] if not set(previous_images).issubset(candidate.metadata['images']): raise ValueError('Catalogue removed existing image paths') + logger.info('农场作物列表、配置和素材校验通过:正在切换版本') _atomic(root / 'current.json', canonical(manifest)) logger.info('farm catalogue ready release=%s source=%s', manifest['release'], source) @@ -186,8 +188,10 @@ def load_catalogue_release(data_dir, source='Github', service_url='', if not set(required_ids).issubset( plant.plant_id for plant in current.catalogue): raise ValueError('catalogue missing player crop ID') + logger.info('农场更新不可用:使用已校验的作物缓存版本') return current bootstrap = _bootstrap(required_ids) + logger.info('农场更新不可用:使用内置作物版本') # 若旧方式图片缓存可用,离线初始版本也能继续绘图。 cached = (Path(data_dir) / 'assets' / bootstrap.manifest['files']['assets.zip']['sha256'] diff --git a/src/farm_render/font_bundle.py b/src/farm_render/font_bundle.py index 932c117..9676fc4 100644 --- a/src/farm_render/font_bundle.py +++ b/src/farm_render/font_bundle.py @@ -3,6 +3,7 @@ import hashlib from importlib import resources import json +import logging from pathlib import Path import tempfile from zipfile import ZipFile @@ -10,6 +11,7 @@ from zipfile import ZipFile from .bundle import _LOCK, _copy_remote, validate_options +logger = logging.getLogger(__name__) FONT_NAME = "Xiaolai-Regular.ttf" FILES = { FONT_NAME: ( @@ -61,6 +63,7 @@ def ensure_font(data_dir, source="Github", service_url=""): with _LOCK: if all(_valid(root / name, *checks) for name, checks in FILES.items()): + logger.info('农场字体:复用已校验缓存') return root / FONT_NAME root.mkdir(parents=True, exist_ok=True) archive_path = root / "assets.zip" @@ -103,4 +106,5 @@ def ensure_font(data_dir, source="Github", service_url=""): # 先写许可,再使字体可用;不使用 extractall 或远端提供的路径。 for name in ("OFL-Xiaolai.txt", FONT_NAME): _atomic_write(root / name, content[name]) + logger.info('农场字体和许可证校验通过') return root / FONT_NAME diff --git a/tests/test_asset_bundle.py b/tests/test_asset_bundle.py index 97e5743..ce00ca1 100644 --- a/tests/test_asset_bundle.py +++ b/tests/test_asset_bundle.py @@ -102,3 +102,45 @@ class BundleTests(unittest.TestCase): with patch.object(bundle, 'urlopen', return_value=response): with self.assertRaises(ValueError): bundle._copy_remote('https://farm.example/a', BytesIO(), 10) + + def test_download_reports_throttled_progress_without_urls(self): + class Response(BytesIO): + headers = {'Content-Length': '262144'} + def geturl(self): + return 'https://farm.example/private/assets.zip' + with patch.object(bundle, 'urlopen', return_value=Response( + b'x' * 262144)), patch.object( + bundle.time, 'monotonic', + side_effect=[0, 0.2, 1.1, 1.2, 2.2]), \ + self.assertLogs(bundle.logger, level='INFO') as captured: + bundle._copy_remote('https://farm.example/private/assets.zip', + BytesIO(), 262144) + messages = '\n'.join(captured.output) + self.assertIn('下载开始', messages) + self.assertIn('50.0%', messages) + self.assertIn('100.0%', messages) + self.assertIn('下载完成', messages) + self.assertEqual(messages.count('下载进度'), 2) + self.assertNotIn('farm.example', messages) + self.assertNotIn('private', messages) + + def test_unknown_length_and_failed_download_report_truthfully(self): + class Response(BytesIO): + headers = {} + def geturl(self): + return 'https://farm.example/a' + with patch.object(bundle, 'urlopen', return_value=Response(b'abc')), \ + patch.object(bundle.time, 'monotonic', side_effect=[0, 1.1]), \ + self.assertLogs(bundle.logger, level='INFO') as captured: + bundle._copy_remote('https://farm.example/a', BytesIO(), 10) + self.assertIn('下载完成', '\n'.join(captured.output)) + self.assertIn('已下载', '\n'.join(captured.output)) + self.assertNotIn('%', '\n'.join(captured.output)) + with patch.object(bundle, 'urlopen', side_effect=OSError('secret')), \ + self.assertLogs(bundle.logger, level='INFO') as captured: + with self.assertRaises(OSError): + bundle._copy_remote('https://farm.example/a', BytesIO(), 10) + messages = '\n'.join(captured.output) + self.assertIn('下载失败', messages) + self.assertNotIn('下载完成', messages) + self.assertNotIn('secret', messages) diff --git a/tests/test_astrbot_avatars.py b/tests/test_astrbot_avatars.py index 8559611..8664319 100644 --- a/tests/test_astrbot_avatars.py +++ b/tests/test_astrbot_avatars.py @@ -1,8 +1,10 @@ import asyncio import importlib.util from pathlib import Path +import sys +from types import ModuleType import unittest -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch ROOT = Path(__file__).resolve().parents[1] @@ -10,6 +12,11 @@ ROOT = Path(__file__).resolve().parents[1] class AvatarTests(unittest.IsolatedAsyncioTestCase): def setUp(self): + api = ModuleType('astrbot.api') + api.logger = Mock() + host = patch.dict(sys.modules, {'astrbot.api': api}) + host.start() + self.addCleanup(host.stop) spec = importlib.util.spec_from_file_location( 'farm_astrbot_avatars', ROOT / 'integrations/astrbot/avatars.py', diff --git a/tests/test_astrbot_package.py b/tests/test_astrbot_package.py index 52b15e3..683309f 100644 --- a/tests/test_astrbot_package.py +++ b/tests/test_astrbot_package.py @@ -7,7 +7,7 @@ import sys import tempfile from types import ModuleType, SimpleNamespace import unittest -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock, Mock, patch ROOT = Path(__file__).resolve().parents[1] @@ -62,19 +62,43 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase): with patch('verify_astrbot.MAX_ARCHIVE_BYTES', 1): with self.assertRaisesRegex(ValueError, '16 MiB'): verify_package(first, marketplace=True) - self.assertEqual(files['runtime.py'], ( - ROOT / 'integrations/shared/runtime.py' - ).read_bytes()) + source_tree = ast.parse((ROOT / 'integrations/shared/runtime.py') + .read_bytes()) + built_tree = ast.parse(files['runtime.py']) + source_runtime = next(node for node in source_tree.body + if isinstance(node, ast.ClassDef)) + built_runtime = next(node for node in built_tree.body + if isinstance(node, ast.ClassDef)) + self.assertEqual(ast.dump(source_runtime), ast.dump(built_runtime)) + self.assertFalse('_vendor/farm_core/__main__.py' in files) for name, content in files.items(): self.assertNotIn('.git/', name) self.assertNotIn('farm.sqlite3', name) if name.endswith('.py'): tree = ast.parse(content) for node in ast.walk(tree): + if isinstance(node, ast.Import): + self.assertFalse(any( + alias.name.split('.')[0] == 'logging' + for alias in node.names + ), name) if isinstance(node, ast.ImportFrom): self.assertFalse((node.module or '').startswith( - ('nonebot', 'zhenxun'), + ('nonebot', 'zhenxun', 'logging'), )) + for snippet in (b'\nimport logging\n', + b'\nlogger.setLevel(20)\n'): + invalid = dict(files) + invalid['main.py'] += snippet + invalid_manifest = json.loads(invalid['FARM-MANIFEST.json']) + invalid_manifest['files']['main.py'] = hashlib.sha256( + invalid['main.py'], + ).hexdigest() + invalid['FARM-MANIFEST.json'] = json.dumps( + invalid_manifest, + ).encode() + with self.assertRaisesRegex(ValueError, 'logging'): + verify_files(invalid) files['main.py'] += b'\n# tampered\n' with self.assertRaisesRegex(ValueError, 'checksum'): verify_files(files) @@ -91,6 +115,7 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase): package.__path__ = [str(self.root / 'host/repository')] api = ModuleType('astrbot.api') api.AstrBotConfig = dict + api.logger = Mock() events = ModuleType('astrbot.api.event') commands = {} @@ -152,6 +177,14 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase): side_effect=OSError('offline fixture'), ): await plugin.initialize() + self.assertIs(main.logger, api.logger) + self.assertIsNone(plugin.runtime.log_handler) + self.assertIsNone(plugin.runtime.console_handler) + self.assertFalse((plugin.runtime.data_dir / + 'farm-preview.log').exists()) + api.logger.info.assert_any_call( + '农场启动:正在检查作物版本和本地缓存', + ) self.assertEqual(set(commands), set(main.COMMANDS)) class Event: diff --git a/tests/test_zhenxun_runtime.py b/tests/test_zhenxun_runtime.py index bc06de6..bfc3fef 100644 --- a/tests/test_zhenxun_runtime.py +++ b/tests/test_zhenxun_runtime.py @@ -84,6 +84,45 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase): for action in ('seeds', 'crops'): self.assertIsInstance(await self.call(action, '1'), bytes) + async def test_start_progress_reaches_console_and_file(self): + import logging + from contextlib import redirect_stderr + from io import StringIO + + await self.runtime.close() + output = StringIO() + package_log = logging.getLogger(self.package) + with patch.object(logging.getLogger(), 'handlers', []), \ + patch.object(package_log, 'handlers', []), \ + redirect_stderr(output): + await self.runtime.start() + await self.runtime.close() + self.assertFalse(package_log.handlers) + self.assertIn('正在检查作物版本', output.getvalue()) + self.assertIn('使用内置作物版本', output.getvalue()) + self.assertIn('字体', output.getvalue()) + self.assertIn('农场启动完成', output.getvalue()) + saved = (self.runtime.data_dir / 'farm-preview.log').read_text( + encoding='utf-8', + ) + self.assertIn('正在检查作物版本', saved) + + async def test_start_reuses_existing_console_without_duplicate_output(self): + import logging + from io import StringIO + + await self.runtime.close() + output = StringIO() + handler = logging.StreamHandler(output) + handler.setLevel(logging.INFO) + with patch.object(logging.getLogger(), 'handlers', [handler]): + await self.runtime.start() + await self.runtime.start() + self.assertIsNone(self.runtime.console_handler) + await self.runtime.close() + self.assertEqual(output.getvalue().count('正在检查作物版本'), 1) + self.assertEqual(output.getvalue().count('农场启动完成'), 1) + async def test_catalogue_is_loaded_once_before_store_initialization(self): import json from unittest.mock import AsyncMock diff --git a/version.txt b/version.txt index 6acdb44..26e3379 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -2.0.3 \ No newline at end of file +2.0.4 \ No newline at end of file