mirror of
https://github.com/Shu-Ying/main_plugin_farm.git
synced 2026-10-28 21:19:58 +08:00
fixed # 修复astrbot日志未使用logging.basicConfig
This commit is contained in:
@@ -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,插件会明确拒绝启动,避免
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 更新日志
|
# 更新日志
|
||||||
|
|
||||||
|
## 待发布
|
||||||
|
|
||||||
|
- 全部插件及私有共享模块日志接入AstrBot官方logger,保留启动下载进度。
|
||||||
|
- 不再配置独立日志文件或修改宿主日志处理器;移除包内开发命令行入口。
|
||||||
|
- 新增发布包日志规范检查,拒绝内置logging及自建日志处理器。
|
||||||
|
|
||||||
## 2.0.2 — 待发布
|
## 2.0.2 — 待发布
|
||||||
|
|
||||||
### 新增
|
### 新增
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 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,
|
||||||
|
|||||||
@@ -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
|
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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
|
|||||||
@@ -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']
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -1 +1 @@
|
|||||||
2.0.3
|
2.0.4
|
||||||
Reference in New Issue
Block a user