mirror of
https://github.com/Shu-Ying/main_plugin_farm.git
synced 2026-10-31 06:49:59 +08:00
fixed # 修复astrbot日志未使用logging.basicConfig
This commit is contained in:
@@ -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,插件会明确拒绝启动,避免
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 更新日志
|
||||
|
||||
## 待发布
|
||||
|
||||
- 全部插件及私有共享模块日志接入AstrBot官方logger,保留启动下载进度。
|
||||
- 不再配置独立日志文件或修改宿主日志处理器;移除包内开发命令行入口。
|
||||
- 新增发布包日志规范检查,拒绝内置logging及自建日志处理器。
|
||||
|
||||
## 2.0.2 — 待发布
|
||||
|
||||
### 新增
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""AstrBot拥有日志配置和输出,插件不创建或关闭任何日志处理器。"""
|
||||
|
||||
|
||||
def open_logs(data_dir):
|
||||
return None, None
|
||||
|
||||
|
||||
def close_logs(file_handler, console_handler):
|
||||
pass
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
@@ -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 = '<!-- FARM_COMMANDS -->'
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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__,
|
||||
)
|
||||
|
||||
+44
-15
@@ -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)
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2.0.3
|
||||
2.0.4
|
||||
Reference in New Issue
Block a user