fixed # 修复astrbot日志未使用logging.basicConfig

This commit is contained in:
2026-10-27 11:40:59 +08:00
parent b3455ded58
commit d05b2e5765
20 changed files with 333 additions and 46 deletions
+9 -1
View File
@@ -31,7 +31,15 @@
启动会核对清单、大小、SHA-256、JSON 契约和 ZIP 图片内容,成功后才原子切换 启动会核对清单、大小、SHA-256、JSON 契约和 ZIP 图片内容,成功后才原子切换
数据目录下的 `catalogue/current.json`。断网、404 或版本无效时优先使用已经 数据目录下的 `catalogue/current.json`。断网、404 或版本无效时优先使用已经
验证的缓存,再使用插件基线。日志位于同一数据目录的 `farm-preview.log`。 验证的缓存,再使用插件基线。真寻和NoneBot日志位于同一数据目录的
`farm-preview.log`;AstrBot统一使用宿主日志,在AstrBot控制台或日志页查看,
插件不创建独立日志文件,也不修改宿主日志级别或处理器。
终端同时显示启动阶段、缓存复用、下载开始、进度、完成及回退信息。下载进度
最多每秒输出一次;服务器提供文件总大小时显示百分比及 MiB,未提供时只显示
已下载量。文件下载完成后仍需校验,只有整套校验通过才切换版本。插件优先
复用宿主已有的 INFO 终端日志通道;真寻和NoneBot没有时添加一个并在关闭时
移除。AstrBot始终使用 `astrbot.api.logger`,输出级别和位置由宿主管理。
备份应包含完整数据目录及其 `catalogue` 缓存。玩家库存或地块若引用基线没有的 备份应包含完整数据目录及其 `catalogue` 缓存。玩家库存或地块若引用基线没有的
新作物 ID,而可用缓存和远端均不能提供这些 ID,插件会明确拒绝启动,避免 新作物 ID,而可用缓存和远端均不能提供这些 ID,插件会明确拒绝启动,避免
+6
View File
@@ -1,5 +1,11 @@
# 更新日志 # 更新日志
## 待发布
- 全部插件及私有共享模块日志接入AstrBot官方logger,保留启动下载进度。
- 不再配置独立日志文件或修改宿主日志处理器;移除包内开发命令行入口。
- 新增发布包日志规范检查,拒绝内置logging及自建日志处理器。
## 2.0.2 — 待发布 ## 2.0.2 — 待发布
### 新增 ### 新增
+1 -2
View File
@@ -2,14 +2,13 @@
import asyncio import asyncio
from collections import OrderedDict from collections import OrderedDict
import logging
import re import re
from time import monotonic from time import monotonic
import aiohttp import aiohttp
from astrbot.api import logger
logger = logging.getLogger(__name__)
MAX_BYTES = 2 * 1024 * 1024 MAX_BYTES = 2 * 1024 * 1024
MAX_ENTRIES = 16 MAX_ENTRIES = 16
FETCH_TIMEOUT = 2 FETCH_TIMEOUT = 2
+1 -4
View File
@@ -2,10 +2,10 @@
import asyncio import asyncio
import json import json
import logging
from pathlib import Path from pathlib import Path
from astrbot.api import AstrBotConfig from astrbot.api import AstrBotConfig
from astrbot.api import logger
from astrbot.api.event import AstrMessageEvent, filter from astrbot.api.event import AstrMessageEvent, filter
from astrbot.api.message_components import At, Image from astrbot.api.message_components import At, Image
from astrbot.api.star import Context, Star, StarTools from astrbot.api.star import Context, Star, StarTools
@@ -16,9 +16,6 @@ from .runtime import Runtime, command_map
COMMANDS = command_map('astrbot') COMMANDS = command_map('astrbot')
logger = logging.getLogger(__name__)
class FarmPlugin(Star): class FarmPlugin(Star):
def __init__(self, context: Context, config: AstrBotConfig): def __init__(self, context: Context, config: AstrBotConfig):
super().__init__(context) super().__init__(context)
+9
View File
@@ -0,0 +1,9 @@
"""AstrBot拥有日志配置和输出,插件不创建或关闭任何日志处理器。"""
def open_logs(data_dir):
return None, None
def close_logs(file_handler, console_handler):
pass
+9 -12
View File
@@ -10,6 +10,7 @@ import shlex
from urllib.error import HTTPError, URLError from urllib.error import HTTPError, URLError
from zipfile import BadZipFile 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 import Catalogue, FarmService, Identity, SQLiteStore
from ._vendor.farm_core.domain import FarmError from ._vendor.farm_core.domain import FarmError
from ._vendor.farm_core.commands import ( from ._vendor.farm_core.commands import (
@@ -133,6 +134,7 @@ class Runtime:
self.rendering = None self.rendering = None
self.started = False self.started = False
self.log_handler = None self.log_handler = None
self.console_handler = None
self.legacy_source = self.data_dir / 'farm.db' self.legacy_source = self.data_dir / 'farm.db'
self._resource_lock = asyncio.Lock() self._resource_lock = asyncio.Lock()
self._closing = False self._closing = False
@@ -153,15 +155,9 @@ class Runtime:
return return
epoch = self._start_epoch epoch = self._start_epoch
self.data_dir.mkdir(parents=True, exist_ok=True) self.data_dir.mkdir(parents=True, exist_ok=True)
self.log_handler = logging.FileHandler( self.log_handler, self.console_handler = open_logs(self.data_dir)
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)
try: try:
logger.info('农场启动:正在检查作物版本和本地缓存')
if self.resource_options_provider is not None: if self.resource_options_provider is not None:
self.resource_source, self.service_url = ( self.resource_source, self.service_url = (
self.resource_options_provider() self.resource_options_provider()
@@ -191,6 +187,8 @@ class Runtime:
if self._closing or epoch != self._start_epoch: if self._closing or epoch != self._start_epoch:
return return
self.started = True self.started = True
logger.info('农场启动完成(%s)',
'图片可用' if self.rendering else '文字模式')
logger.info("farm preview initialized schema=%s", SCHEMA_VERSION) logger.info("farm preview initialized schema=%s", SCHEMA_VERSION)
async def _prepare_resources(self): async def _prepare_resources(self):
@@ -206,6 +204,7 @@ class Runtime:
) )
validate_options(self.resource_source, self.service_url) validate_options(self.resource_source, self.service_url)
resource = 'assets' resource = 'assets'
logger.info('农场启动:正在准备图片素材和字体')
selected = self.catalogue_release selected = self.catalogue_release
bundle_path = selected.bundle_path if selected else None bundle_path = selected.bundle_path if selected else None
if bundle_path is None: if bundle_path is None:
@@ -265,10 +264,8 @@ class Runtime:
await self.store.close() await self.store.close()
self.started = False self.started = False
self._closing = False self._closing = False
if self.log_handler: close_logs(self.log_handler, self.console_handler)
logging.getLogger(__package__).removeHandler(self.log_handler) self.log_handler = self.console_handler = None
self.log_handler.close()
self.log_handler = None
async def execute( async def execute(
self, action, arguments, *, bot_id, platform, user_id, message_id, self, action, arguments, *, bot_id, platform, user_id, message_id,
+43
View File
@@ -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()
+33 -1
View File
@@ -15,6 +15,33 @@ from verify_zhenxun import read_package as read_shared
from help_image import build_help_image 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): def render_readme(root):
template = (root / 'readme/astrbot.md').read_text(encoding='utf-8') template = (root / 'readme/astrbot.md').read_text(encoding='utf-8')
marker = '<!-- FARM_COMMANDS -->' marker = '<!-- FARM_COMMANDS -->'
@@ -49,7 +76,7 @@ def build(root, output, *, help_image=None):
files = {name: content for name, content in shared.items() files = {name: content for name, content in shared.items()
if name.startswith('_vendor/') or name in ( if name.startswith('_vendor/') or name in (
'_version.py', 'version.txt', 'LICENSE', '_version.py', 'version.txt', 'LICENSE',
)} ) if not name.endswith('/__main__.py')}
source = root / 'integrations/astrbot' source = root / 'integrations/astrbot'
for name in ('main.py', 'avatars.py', '_conf_schema.json', for name in ('main.py', 'avatars.py', '_conf_schema.json',
'requirements.txt', 'CHANGELOG.md'): 'requirements.txt', 'CHANGELOG.md'):
@@ -65,6 +92,9 @@ def build(root, output, *, help_image=None):
files['runtime.py'] = ( files['runtime.py'] = (
root / 'integrations/shared/runtime.py' root / 'integrations/shared/runtime.py'
).read_bytes() ).read_bytes()
files['runtime_logging.py'] = (
source / 'runtime_logging.py'
).read_bytes()
files['verify_package.py'] = ( files['verify_package.py'] = (
root / 'scripts/verify_astrbot.py' root / 'scripts/verify_astrbot.py'
).read_bytes() ).read_bytes()
@@ -82,6 +112,8 @@ def build(root, output, *, help_image=None):
}, ensure_ascii=False, indent=2) + '\n').encode() }, ensure_ascii=False, indent=2) + '\n').encode()
for name, content in files.items(): for name, content in files.items():
if name.endswith('.py'): if name.endswith('.py'):
content = host_logging(content)
files[name] = content
ast.parse(content, filename=name, feature_version=(3, 10)) ast.parse(content, filename=name, feature_version=(3, 10))
manifest = { manifest = {
'schema': 1, 'target': 'astrbot', 'plugin': ROOT_NAME, 'schema': 1, 'target': 'astrbot', 'plugin': ROOT_NAME,
+3
View File
@@ -32,6 +32,9 @@ def build(root, output, *, help_image=None):
files[prefix + "runtime.py"] = ( files[prefix + "runtime.py"] = (
root / "integrations/shared/runtime.py" root / "integrations/shared/runtime.py"
).read_bytes() ).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' files[prefix + "_vendor/__init__.py"] = b'"""Private farm modules."""\n'
for package in ("farm_core", "farm_render"): for package in ("farm_core", "farm_render"):
source = root / "src" / package source = root / "src" / package
+3
View File
@@ -57,6 +57,9 @@ def build(root, output, *, help_image=None):
files["runtime.py"] = ( files["runtime.py"] = (
root / "integrations/shared/runtime.py" root / "integrations/shared/runtime.py"
).read_bytes() ).read_bytes()
files['runtime_logging.py'] = (
root / 'integrations/shared/runtime_logging.py'
).read_bytes()
files["_vendor/__init__.py"] = b'"""Private shared farm modules."""\n' files["_vendor/__init__.py"] = b'"""Private shared farm modules."""\n'
for package in ("farm_core", "farm_render"): for package in ("farm_core", "farm_render"):
source = root / "src" / package source = root / "src" / package
+33 -1
View File
@@ -1,6 +1,7 @@
"""AstrBot插件ZIP的独立完整性校验;只使用标准库。""" """AstrBot插件ZIP的独立完整性校验;只使用标准库。"""
import argparse import argparse
import ast
import hashlib import hashlib
import json import json
from pathlib import Path, PurePosixPath from pathlib import Path, PurePosixPath
@@ -37,8 +38,39 @@ def verify_files(files):
safe_path(name) safe_path(name)
if hashlib.sha256(files[name]).hexdigest() != digest: if hashlib.sha256(files[name]).hexdigest() != digest:
raise ValueError('Package checksum mismatch') 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', 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', 'logo-readme.png',
'images/farm-preview.png', 'images/farm-preview.png',
'requirements.txt', 'LICENSE', 'version.txt', '_version.py', 'requirements.txt', 'LICENSE', 'version.txt', '_version.py',
+3 -3
View File
@@ -434,9 +434,9 @@ class SQLiteStore:
connection.commit() connection.commit()
except BaseException as exc: except BaseException as exc:
connection.rollback() connection.rollback()
LOGGER.log( report = (LOGGER.info if isinstance(exc, FarmError)
logging.INFO if isinstance(exc, FarmError) else LOGGER.warning)
else logging.WARNING, report(
"farm rollback action=%s operation=%s error=%s", "farm rollback action=%s operation=%s error=%s",
action, operation_id, type(exc).__name__, action, operation_id, type(exc).__name__,
) )
+44 -15
View File
@@ -43,21 +43,48 @@ def checked_url(url):
def _copy_remote(url, destination, limit): def _copy_remote(url, destination, limit):
request = Request(checked_url(url), headers={'User-Agent': 'Farm-assets/1'}) 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() started = time.monotonic()
with urlopen(request, timeout=15) as response: last_report = started
checked_url(response.geturl()) logger.info('农场下载开始:%s', name)
length = response.headers.get('Content-Length') try:
if length is not None and int(length) > limit: with urlopen(request, timeout=15) as response:
raise ValueError('Resource download exceeds limit') checked_url(response.geturl())
total = 0 header = response.headers.get('Content-Length')
while True: length = int(header) if header is not None else 0
chunk = response.read(min(65536, limit-total+1)) if length > limit:
if not chunk: raise ValueError('Resource download exceeds limit')
break total = 0
total += len(chunk) while True:
if total > limit or time.monotonic()-started > 60: chunk = response.read(min(65536, limit-total+1))
raise ValueError('Resource download limit or timeout') if not chunk:
destination.write(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): 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') target = (Path(data_dir) / 'assets' / expected['sha256'] / 'assets.zip')
with _LOCK: with _LOCK:
try: try:
return verify_bundle(target, expected) cached = verify_bundle(target, expected)
logger.info('农场图片素材:复用已校验缓存')
return cached
except (OSError, ValueError): except (OSError, ValueError):
pass pass
target.parent.mkdir(parents=True, exist_ok=True) target.parent.mkdir(parents=True, exist_ok=True)
+4
View File
@@ -140,6 +140,7 @@ def load_catalogue_release(data_dir, source='Github', service_url='',
if not set(required_ids).issubset( if not set(required_ids).issubset(
plant.plant_id for plant in current.catalogue): plant.plant_id for plant in current.catalogue):
raise ValueError('catalogue missing player crop ID') raise ValueError('catalogue missing player crop ID')
logger.info('农场作物版本未变化:复用已校验缓存')
return replace(current, origin='remote') return replace(current, origin='remote')
try: try:
candidate = _snapshot(target, manifest, previous, required_ids, 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'] previous_images = (current or bootstrap).metadata['images']
if not set(previous_images).issubset(candidate.metadata['images']): if not set(previous_images).issubset(candidate.metadata['images']):
raise ValueError('Catalogue removed existing image paths') raise ValueError('Catalogue removed existing image paths')
logger.info('农场作物列表、配置和素材校验通过:正在切换版本')
_atomic(root / 'current.json', canonical(manifest)) _atomic(root / 'current.json', canonical(manifest))
logger.info('farm catalogue ready release=%s source=%s', logger.info('farm catalogue ready release=%s source=%s',
manifest['release'], source) manifest['release'], source)
@@ -186,8 +188,10 @@ def load_catalogue_release(data_dir, source='Github', service_url='',
if not set(required_ids).issubset( if not set(required_ids).issubset(
plant.plant_id for plant in current.catalogue): plant.plant_id for plant in current.catalogue):
raise ValueError('catalogue missing player crop ID') raise ValueError('catalogue missing player crop ID')
logger.info('农场更新不可用:使用已校验的作物缓存版本')
return current return current
bootstrap = _bootstrap(required_ids) bootstrap = _bootstrap(required_ids)
logger.info('农场更新不可用:使用内置作物版本')
# 若旧方式图片缓存可用,离线初始版本也能继续绘图。 # 若旧方式图片缓存可用,离线初始版本也能继续绘图。
cached = (Path(data_dir) / 'assets' cached = (Path(data_dir) / 'assets'
/ bootstrap.manifest['files']['assets.zip']['sha256'] / bootstrap.manifest['files']['assets.zip']['sha256']
+4
View File
@@ -3,6 +3,7 @@
import hashlib import hashlib
from importlib import resources from importlib import resources
import json import json
import logging
from pathlib import Path from pathlib import Path
import tempfile import tempfile
from zipfile import ZipFile from zipfile import ZipFile
@@ -10,6 +11,7 @@ from zipfile import ZipFile
from .bundle import _LOCK, _copy_remote, validate_options from .bundle import _LOCK, _copy_remote, validate_options
logger = logging.getLogger(__name__)
FONT_NAME = "Xiaolai-Regular.ttf" FONT_NAME = "Xiaolai-Regular.ttf"
FILES = { FILES = {
FONT_NAME: ( FONT_NAME: (
@@ -61,6 +63,7 @@ def ensure_font(data_dir, source="Github", service_url=""):
with _LOCK: with _LOCK:
if all(_valid(root / name, *checks) if all(_valid(root / name, *checks)
for name, checks in FILES.items()): for name, checks in FILES.items()):
logger.info('农场字体:复用已校验缓存')
return root / FONT_NAME return root / FONT_NAME
root.mkdir(parents=True, exist_ok=True) root.mkdir(parents=True, exist_ok=True)
archive_path = root / "assets.zip" archive_path = root / "assets.zip"
@@ -103,4 +106,5 @@ def ensure_font(data_dir, source="Github", service_url=""):
# 先写许可,再使字体可用;不使用 extractall 或远端提供的路径。 # 先写许可,再使字体可用;不使用 extractall 或远端提供的路径。
for name in ("OFL-Xiaolai.txt", FONT_NAME): for name in ("OFL-Xiaolai.txt", FONT_NAME):
_atomic_write(root / name, content[name]) _atomic_write(root / name, content[name])
logger.info('农场字体和许可证校验通过')
return root / FONT_NAME return root / FONT_NAME
+42
View File
@@ -102,3 +102,45 @@ class BundleTests(unittest.TestCase):
with patch.object(bundle, 'urlopen', return_value=response): with patch.object(bundle, 'urlopen', return_value=response):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
bundle._copy_remote('https://farm.example/a', BytesIO(), 10) 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)
+8 -1
View File
@@ -1,8 +1,10 @@
import asyncio import asyncio
import importlib.util import importlib.util
from pathlib import Path from pathlib import Path
import sys
from types import ModuleType
import unittest import unittest
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, Mock, patch
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
@@ -10,6 +12,11 @@ ROOT = Path(__file__).resolve().parents[1]
class AvatarTests(unittest.IsolatedAsyncioTestCase): class AvatarTests(unittest.IsolatedAsyncioTestCase):
def setUp(self): 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( spec = importlib.util.spec_from_file_location(
'farm_astrbot_avatars', 'farm_astrbot_avatars',
ROOT / 'integrations/astrbot/avatars.py', ROOT / 'integrations/astrbot/avatars.py',
+38 -5
View File
@@ -7,7 +7,7 @@ import sys
import tempfile import tempfile
from types import ModuleType, SimpleNamespace from types import ModuleType, SimpleNamespace
import unittest import unittest
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, Mock, patch
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
@@ -62,19 +62,43 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
with patch('verify_astrbot.MAX_ARCHIVE_BYTES', 1): with patch('verify_astrbot.MAX_ARCHIVE_BYTES', 1):
with self.assertRaisesRegex(ValueError, '16 MiB'): with self.assertRaisesRegex(ValueError, '16 MiB'):
verify_package(first, marketplace=True) verify_package(first, marketplace=True)
self.assertEqual(files['runtime.py'], ( source_tree = ast.parse((ROOT / 'integrations/shared/runtime.py')
ROOT / 'integrations/shared/runtime.py' .read_bytes())
).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(): for name, content in files.items():
self.assertNotIn('.git/', name) self.assertNotIn('.git/', name)
self.assertNotIn('farm.sqlite3', name) self.assertNotIn('farm.sqlite3', name)
if name.endswith('.py'): if name.endswith('.py'):
tree = ast.parse(content) tree = ast.parse(content)
for node in ast.walk(tree): 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): if isinstance(node, ast.ImportFrom):
self.assertFalse((node.module or '').startswith( 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' files['main.py'] += b'\n# tampered\n'
with self.assertRaisesRegex(ValueError, 'checksum'): with self.assertRaisesRegex(ValueError, 'checksum'):
verify_files(files) verify_files(files)
@@ -91,6 +115,7 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
package.__path__ = [str(self.root / 'host/repository')] package.__path__ = [str(self.root / 'host/repository')]
api = ModuleType('astrbot.api') api = ModuleType('astrbot.api')
api.AstrBotConfig = dict api.AstrBotConfig = dict
api.logger = Mock()
events = ModuleType('astrbot.api.event') events = ModuleType('astrbot.api.event')
commands = {} commands = {}
@@ -152,6 +177,14 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
side_effect=OSError('offline fixture'), side_effect=OSError('offline fixture'),
): ):
await plugin.initialize() 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)) self.assertEqual(set(commands), set(main.COMMANDS))
class Event: class Event:
+39
View File
@@ -84,6 +84,45 @@ class ZhenxunRuntimeTests(unittest.IsolatedAsyncioTestCase):
for action in ('seeds', 'crops'): for action in ('seeds', 'crops'):
self.assertIsInstance(await self.call(action, '1'), bytes) 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): async def test_catalogue_is_loaded_once_before_store_initialization(self):
import json import json
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
+1 -1
View File
@@ -1 +1 @@
2.0.3 2.0.4