Compare commits

..
7 Commits
Author SHA1 Message Date
Diuse e897bad506 fixed # 还是修复CI问题 2026-10-28 09:49:06 +08:00
Diuse b851e490be fixed # 修复CI问题 2026-10-28 09:28:33 +08:00
Diuse 8872ffe3be fixed # 2.0.6 2026-10-27 23:03:17 +08:00
Diuse b09d51c7c9 fixed # 修改进度条表现
add # 新增Ruff代码格式工作流
2026-10-27 23:02:43 +08:00
Diuse c6b504924e fixed # 修复部分工作流BUG 2026-10-27 18:43:42 +08:00
Diuse d05b2e5765 fixed # 修复astrbot日志未使用logging.basicConfig 2026-10-27 11:40:59 +08:00
Diuse b3455ded58 fixed # 修复工作流BUG 2026-10-27 09:47:48 +08:00
46 changed files with 983 additions and 173 deletions
+2 -2
View File
@@ -33,7 +33,7 @@ jobs:
- run: >-
python -m pip install "Pillow>=10.4,<13"
setuptools wheel "PyYAML>=6,<7" build
-r integrations/astrbot/requirements.txt
-r integrations/astrbot/requirements.txt -r requirements-build.txt
- run: python scripts/ci_font.py
- run: python scripts/check_workflows.py
- run: python scripts/verify_foundation.py
@@ -51,7 +51,7 @@ jobs:
run: python scripts/check_asset_manifest.py
- name: Install help rendering dependencies
run: |
python -m pip install "Pillow>=10.4,<13"
python -m pip install "Pillow>=10.4,<13" -r requirements-build.txt
sudo apt-get update
sudo apt-get install -y fonts-noto-cjk
- run: python scripts/ci_font.py
+5 -5
View File
@@ -6,7 +6,7 @@ on:
workflow_dispatch:
inputs:
publish:
description: Sync main branch in NoneBot repository
description: Sync NoneBot repository and trigger PyPI version release
default: false
type: boolean
permissions:
@@ -33,7 +33,7 @@ jobs:
- run: >-
python -m pip install "Pillow>=10.4,<13"
setuptools wheel "PyYAML>=6,<7" build
-r integrations/astrbot/requirements.txt
-r integrations/astrbot/requirements.txt -r requirements-build.txt
- run: python scripts/ci_font.py
- run: python scripts/check_workflows.py
- run: python scripts/verify_foundation.py
@@ -51,7 +51,7 @@ jobs:
run: python scripts/check_asset_manifest.py
- name: Install help rendering dependencies
run: |
python -m pip install "Pillow>=10.4,<13"
python -m pip install "Pillow>=10.4,<13" -r requirements-build.txt
sudo apt-get update
sudo apt-get install -y fonts-noto-cjk
- run: python scripts/ci_font.py
@@ -102,9 +102,9 @@ jobs:
with:
name: nonebot-preview
path: incoming
- name: Sync validated NoneBot repository
- name: Sync NoneBot repository and trigger version release
env:
FARM_RELEASE_TOKEN: ${{ secrets.FARM_RELEASE_TOKEN }}
run: >-
python scripts/publish_nonebot.py
incoming/nonebot_plugin_farm-*.zip --publish
incoming/nonebot_plugin_farm-*.zip --publish --release-tag
+2 -2
View File
@@ -42,7 +42,7 @@ jobs:
run: >-
python -m pip install "Pillow==$PILLOW_VERSION"
setuptools wheel "PyYAML>=6,<7"
-r integrations/astrbot/requirements.txt
-r integrations/astrbot/requirements.txt -r requirements-build.txt
- run: python scripts/ci_font.py
- run: python scripts/check_workflows.py
- run: python scripts/verify_foundation.py
@@ -61,7 +61,7 @@ jobs:
run: python scripts/check_asset_manifest.py
- name: Install help rendering dependencies
run: |
python -m pip install "Pillow>=10.4,<13"
python -m pip install "Pillow>=10.4,<13" -r requirements-build.txt
sudo apt-get update
sudo apt-get install -y fonts-noto-cjk
- run: python scripts/ci_font.py
+18
View File
@@ -38,6 +38,24 @@
`如果没有你需要的框架,那么请在本仓库提交issue吧,Codex会尽量迁移的`
## 本地生成插件
构建环境需要 Python 3.10+、Pillow 和固定版本的 Ruff:
```powershell
python -m pip install "Pillow>=10.4,<13" -r requirements-build.txt
```
运行对应平台的 `build_*.cmd`,或使用
`python scripts/build_plugin.py nonebot`(也支持 `zhenxun`、`astrbot`)。
如需指定 Python,可设置 `FARM_PYTHON` 为已安装以上依赖的解释器路径。
三个平台都会先对生成的 Python 文件整理导入并使用 Ruff 格式化,检查通过后
再计算清单摘要和打包。配置沿用本仓库 `pyproject.toml`:Python 3.10、
80 字符行宽、4 空格缩进、双引号和 LF 换行。构建不修改开发源文件,
也不会将 Ruff 加入插件运行依赖;缺少 Ruff、版本不符或格式检查失败时停止构建。
这里只检查导入排序和格式,不自动修复其他代码规则;功能仍需单独测试。
## 开源协议
本项目采用的是`GPLv3`许可证进行开源
+1
View File
@@ -6,6 +6,7 @@
| 文档 | 用途 |
|---|---|
| [nonebot-release.md](nonebot-release.md) | NoneBot 同步、自动版本标签、PyPI发布及失败重跑 |
| [catalogue-updates.md](catalogue-updates.md) | Service 作物维护、发布、启动更新、缓存与兼容回滚 |
| [23-seasonal-events-and-rust-service.md](23-seasonal-events-and-rust-service.md) | 节日活动交互备选、Rust 发布中心、同步、资产事务、过期清理与上线规划(草案) |
| [09-foundation.md](09-foundation.md) | 已实现的第一批核心、API、测试与限制 |
+13 -1
View File
@@ -31,7 +31,19 @@
启动会核对清单、大小、SHA-256、JSON 契约和 ZIP 图片内容,成功后才原子切换
数据目录下的 `catalogue/current.json`。断网、404 或版本无效时优先使用已经
验证的缓存,再使用插件基线。日志位于同一数据目录的 `farm-preview.log`。
验证的缓存,再使用插件基线。真寻和NoneBot日志位于同一数据目录的
`farm-preview.log`;AstrBot统一使用宿主日志,在AstrBot控制台或日志页查看,
插件不创建独立日志文件,也不修改宿主日志级别或处理器。
终端同时显示启动阶段、缓存复用、下载开始、完成及回退信息。交互终端中的
Rich 下载进度条在同一行刷新,最多每0.2秒一次;显示已下载量和下载速度,
服务器提供文件总大小时还显示百分比、总大小和预计剩余时间。
未提供总大小时不显示百分比和预计剩余时间。网页日志页、重定向输出和日志文件
不记录中间进度,避免刷屏或混入控制字符。文件下载完成后仍需校验,只有
整套校验通过才切换版本。插件优先
复用宿主已有的 INFO 终端日志通道;真寻和NoneBot没有时添加一个并在关闭时
移除。AstrBot普通日志使用 `astrbot.api.logger`,输出级别和位置由宿主管理;
进度条只写入可原位刷新的交互终端,不进入宿主日志。
备份应包含完整数据目录及其 `catalogue` 缓存。玩家库存或地块若引用基线没有的
新作物 ID,而可用缓存和远端均不能提供这些 ID,插件会明确拒绝启动,避免
+34
View File
@@ -0,0 +1,34 @@
# NoneBot 发布与版本更新
主仓库 `version.txt` 是版本来源。NoneBot preview 工作流通过测试并生成插件后,
在主仓库 `main` 推送或手动勾选 `publish` 时,执行以下操作:
1. 校验生成包及来源工作区状态。
2. 同步 `Shu-Ying/nonebot_plugin_farm` 的 `main` 分支。
3. 在本次同步提交上创建 `v<version.txt>` 标签。
4. 标签触发独立仓库的“🚀 发布到 PyPI”工作流,构建、校验并上传发行包。
PR及未勾选publish的手动运行只构建测试,不同步、不打标签。脚本未传
`--publish` 时仍是离线预演;`--release-tag` 明确开启自动标签步骤。
标签创建成功只表示触发条件已满足,不表示下游PyPI发布或商店检测已成功。
## 凭据与发布前提
- 主仓库受保护环境中继续使用 `FARM_RELEASE_TOKEN`,必须是可写目标仓库
内容及工作流的PAT或GitHub App token;不能替换为普通 `GITHUB_TOKEN`。
- NoneBot独立仓库需允许Actions运行,并已配置有效的 `PYPI_TOKEN`。
- 目标仓库标签规则也必须允许该发布身份创建 `v*` 标签。
- 发布新内容前递增主仓库 `version.txt`;不要在发布仓库手工改生成的版本文件。
## 重跑和失败处理
已有同版本标签时,比较标签中生成清单的版本、包身份和全部文件摘要;内容
一致则跳过同步和重复触发。只有来源提交信息变化而插件文件相同也会跳过。
若内容不同则拒绝,并提示升级 `version.txt`;脚本不删除或移动旧标签。
同步main成功但创建标签失败时,工作流会报错,保留已同步代码;修复权限等
原因后重跑。若标签已创建而下游PyPI上传失败,应在独立仓库重新运行失败的
发布任务;上游重跑不会再次创建同名标签。PyPI已占用的版本不能靠移动标签
覆盖,应使用新版本。
本地测试使用模拟GitHub请求,不会创建远端标签或上传PyPI。
+6
View File
@@ -1,5 +1,11 @@
# 更新日志
## 待发布
- 全部插件及私有共享模块日志接入AstrBot官方logger,保留启动下载进度。
- 不再配置独立日志文件或修改宿主日志处理器;移除包内开发命令行入口。
- 新增发布包日志规范检查,拒绝内置logging及自建日志处理器。
## 2.0.2 — 待发布
### 新增
+1 -2
View File
@@ -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
+1 -4
View File
@@ -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)
+1
View File
@@ -1,2 +1,3 @@
Pillow>=10.4
aiohttp>=3.9,<4
rich>=13.9,<16
+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 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,
+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()
+1 -2
View File
@@ -3,7 +3,6 @@
from nonebot.plugin import PluginMetadata
from zhenxun.configs.utils import Command, PluginExtraData, RegisterConfig
from ._version import __version__
from .runtime import COMMANDS, HELP
__plugin_meta__ = PluginMetadata(
@@ -12,7 +11,7 @@ __plugin_meta__ = PluginMetadata(
homepage="https://github.com/Shu-Ying/zhenxun_plugin_farm",
supported_adapters={"~onebot.v11", "~qq"},
extra=PluginExtraData(
author="Shu-Ying", version=__version__, menu_type="群内小游戏",
author="Shu-Ying", version="__FARM_VERSION__", menu_type="群内小游戏",
commands=[Command(command=name) for name in COMMANDS],
configs=[RegisterConfig(
key="农场图片质量", value=75, default_value=75, type=int,
+1
View File
@@ -1,3 +1,4 @@
# 使用宿主已有的NoneBot/真寻/Alconna/Uninfo,不重复安装宿主。
# 与真寻当前pillow<11的约束取交集,不升级到12。
Pillow>=10.4,<11
rich>=13.9,<16
+8 -1
View File
@@ -12,7 +12,7 @@ license = {file = "LICENSE"}
dependencies = []
[project.optional-dependencies]
render = ["Pillow>=10.4,<13"]
render = ["Pillow>=10.4,<13", "rich>=13.9,<16"]
[tool.setuptools.packages.find]
where = ["src"]
@@ -25,6 +25,13 @@ farm_core = ["data/*.json"]
farm_render = ["data/*.json", "data/*.zip", "data/*.md", "data/*.txt"]
[tool.ruff]
required-version = "==0.8.6"
line-length = 80
target-version = "py310"
indent-width = 4
extend-exclude = ["legacy", ".agents", ".comet"]
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
line-ending = "lf"
+2 -2
View File
@@ -49,9 +49,9 @@
---
## 如何安装
在Astrbot的插件商店下载即可
方法一(推荐):在小真寻后台的插件商店下载即可<br>
方法二:下载源码放在小真寻`plugin`目录下
`在安装后,每次启动均会向`[farm_service](https://github.com/Shu-Ying/farm_service)`仓库进行下载资源,默认使用Github进行下载,可以在设置中切换为Gitea镜像`
---
+3 -16
View File
@@ -50,24 +50,11 @@
## 如何安装
使用真寻分发仓库的完整生成内容或Actions中的真寻预览包,不能直接
复制主仓库的integrations目录。先停止机器人,备份代码与数据,停用
会注册同名命令的旧插件,再在宿主环境安装本插件requirements.txt。
Pillow要求>=10.4,<11,不强制升级宿主到12。安装后手工启动并检查日志。
方法一(推荐):在小真寻后台的插件商店下载即可
默认数据目录为真寻DATA_PATH下的farm_db,新库为farm.sqlite3,日志为
farm-preview.log;旧farm.db不得直接改名覆盖。资源源默认Github,可在
网页配置选择Gitea/farm_service;服务地址独立保留,修改资源源后重载。
图片质量默认75、整数1~95,仅“我的农场 高清”支持高清且质量至少85。
资源或字体下载失败时文字业务仍可用,详细原因见日志。
方法二:下载源码放在小真寻`plugin`目录下
升级前停机备份完整代码与数据目录;安装器拒绝覆盖,先移走旧目录。
回退需匹配代码和数据库备份,不让旧代码强行打开新schema;恢复旧数据
会丢失备份后进度。卸载时停止加载并移走插件代码,保留数据供恢复。
开发者构建、包验证、安全安装、配置、备份卸载与Secrets详见
[当前维护说明](https://github.com/Shu-Ying/main_plugin_farm/blob/main/docs/zhenxun-preview-current.md)。
本地离线验证不代表真实真寻宿主、公开资源或远端发布已验证。
`在安装后,每次启动均会向`[farm_service](https://github.com/Shu-Ying/farm_service)`仓库进行下载资源,默认使用Github进行下载,可以在设置中切换为Gitea镜像`
---
+3 -3
View File
@@ -31,12 +31,12 @@ jobs:
raise SystemExit('Tag differs from version.txt')
PY
- run: python -m pip install build twine
- run: python -m build --sdist --wheel --outdir ../packages
- run: python -m twine check ../packages/*
- run: python -m build --sdist --wheel --outdir dist
- run: python -m twine check dist/*
- uses: actions/upload-artifact@v7
with:
name: pypi-distributions
path: ../packages/*
path: dist/*
if-no-files-found: error
publish:
needs: build
+1
View File
@@ -16,6 +16,7 @@ dependencies = [
"nonebot-plugin-uninfo>=0.6,<1",
"nonebot-plugin-localstore>=0.7,<1",
"Pillow>=10.4,<13",
"rich>=13.9,<16",
]
[project.urls]
+2 -2
View File
@@ -18,9 +18,9 @@ jobs:
python-version: '3.10'
- run: python verify_package.py --directory .
- run: python -m pip install build
- run: python -m build --sdist --wheel --outdir ../packages
- run: python -m build --sdist --wheel --outdir dist
- uses: actions/upload-artifact@v7
with:
name: nonebot-distributions
path: ../packages/*
path: dist/*
if-no-files-found: error
+2
View File
@@ -0,0 +1,2 @@
# Keep in sync with tool.ruff.required-version in pyproject.toml.
ruff==0.8.6
+153 -72
View File
@@ -5,125 +5,206 @@ import ast
import hashlib
import json
import runpy
from pathlib import Path
import tempfile
from pathlib import Path
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
from build_zhenxun import build as build_shared, read_version, source_identity
from build_zhenxun import build as build_shared
from build_zhenxun import read_version, source_identity
from help_image import build_help_image
from format_generated import format_generated
from verify_astrbot import MANIFEST, ROOT_NAME, verify_files, verify_package
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 -->'
template = (root / "readme/astrbot.md").read_text(encoding="utf-8")
marker = "<!-- FARM_COMMANDS -->"
if marker not in template:
if './images/help-astrbot.png' not in template:
raise ValueError('AstrBot README requires its help menu image')
return template.encode('utf-8')
if "./images/help-astrbot.png" not in template:
raise ValueError("AstrBot README requires its help menu image")
return template.encode("utf-8")
if template.count(marker) != 1:
raise ValueError('AstrBot README requires one command marker')
definitions = runpy.run_path(str(root / 'src/farm_core/commands.py'))
lines = ['| 分类 | 指令 | 说明 |', '| --- | --- | --- |']
specs = definitions['command_specs']('astrbot')
for group, rows in definitions['help_groups'](specs):
raise ValueError("AstrBot README requires one command marker")
definitions = runpy.run_path(str(root / "src/farm_core/commands.py"))
lines = ["| 分类 | 指令 | 说明 |", "| --- | --- | --- |"]
specs = definitions["command_specs"]("astrbot")
for group, rows in definitions["help_groups"](specs):
for usage, description in rows:
values = (group, f'`/{usage}`', description)
lines.append('| ' + ' | '.join(
value.replace('|', r'\|').replace('\n', ' ')
for value in values
) + ' |')
return template.replace(marker, '\n'.join(lines)).encode('utf-8')
values = (group, f"`/{usage}`", description)
lines.append(
"| "
+ " | ".join(
value.replace("|", r"\|").replace("\n", " ")
for value in values
)
+ " |"
)
return template.replace(marker, "\n".join(lines)).encode("utf-8")
def build(root, output, *, help_image=None):
root, output = Path(root).resolve(), Path(output).resolve()
version = read_version(root)
if output.exists():
raise FileExistsError('Use a new output directory')
if output.is_relative_to(root) and not output.is_relative_to(root / 'dist'):
raise ValueError('In-project output must be inside dist')
raise FileExistsError("Use a new output directory")
if output.is_relative_to(root) and not output.is_relative_to(root / "dist"):
raise ValueError("In-project output must be inside dist")
with tempfile.TemporaryDirectory() as temporary:
shared = read_shared(build_shared(root, Path(temporary) / 'shared'))
files = {name: content for name, content in shared.items()
if name.startswith('_vendor/') or name in (
'_version.py', 'version.txt', 'LICENSE',
)}
source = root / 'integrations/astrbot'
for name in ('main.py', 'avatars.py', '_conf_schema.json',
'requirements.txt', 'CHANGELOG.md'):
shared = read_shared(build_shared(root, Path(temporary) / "shared"))
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",
):
files[name] = (source / name).read_bytes()
# AstrBot识别固定文件名logo.png;README单独使用高清原图。
files['logo.png'] = (root / 'logo-astrbot.png').read_bytes()
files['logo-readme.png'] = (root / 'logo.png').read_bytes()
files['images/farm-preview.png'] = shared['images/farm-preview.png']
files['images/help-astrbot.png'] = build_help_image(
root, 'astrbot', help_image,
files["logo.png"] = (root / "logo-astrbot.png").read_bytes()
files["logo-readme.png"] = (root / "logo.png").read_bytes()
files["images/farm-preview.png"] = shared["images/farm-preview.png"]
files["images/help-astrbot.png"] = build_help_image(
root,
"astrbot",
help_image,
)
files['README.md'] = render_readme(root)
files['runtime.py'] = (
root / 'integrations/shared/runtime.py'
).read_bytes()
files['verify_package.py'] = (
root / 'scripts/verify_astrbot.py'
files["README.md"] = render_readme(root)
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()
# JSON是YAML的子集;标准库即可生成AstrBot可读的metadata.yaml。
files['metadata.yaml'] = (json.dumps({
'name': ROOT_NAME, 'display_name': '农场',
'short_desc': '开通农场,体验种植、收获、偷菜与土地开垦。',
'desc': '农场种植小游戏,支持种子商店、分类仓库、作物交易、'
'除虫、偷菜、土地开垦和动态图片帮助;使用本地存档。',
'version': version, 'author': 'Shu-Ying',
'repo': 'https://github.com/Shu-Ying/astrbot_plugin_farm',
'social_link': 'https://github.com/Shu-Ying',
'tags': ['农场', '种植', '小游戏'],
'support_platforms': ['aiocqhttp'],
}, ensure_ascii=False, indent=2) + '\n').encode()
files["metadata.yaml"] = (
json.dumps(
{
"name": ROOT_NAME,
"display_name": "真寻农场",
"short_desc": "开通农场,体验种植、收获、偷菜与土地开垦。",
"desc": "农场种植小游戏,支持种子商店、分类仓库、作物交易、"
"除虫、偷菜、土地开垦和动态图片帮助;使用本地存档。",
"version": version,
"author": "Shu-Ying",
"repo": "https://github.com/Shu-Ying/astrbot_plugin_farm",
"social_link": "https://github.com/Shu-Ying",
"tags": ["农场", "种植", "小游戏"],
"support_platforms": ["aiocqhttp"],
},
ensure_ascii=False,
indent=2,
)
+ "\n"
).encode()
for name, content in files.items():
if name.endswith('.py'):
if name.endswith(".py"):
files[name] = host_logging(content)
format_generated(root, files)
for name, content in files.items():
if name.endswith(".py"):
ast.parse(content, filename=name, feature_version=(3, 10))
manifest = {
'schema': 1, 'target': 'astrbot', 'plugin': ROOT_NAME,
'version': version, 'source': source_identity(root),
'files': {name: hashlib.sha256(content).hexdigest()
for name, content in sorted(files.items())},
"schema": 1,
"target": "astrbot",
"plugin": ROOT_NAME,
"version": version,
"source": source_identity(root),
"files": {
name: hashlib.sha256(content).hexdigest()
for name, content in sorted(files.items())
},
}
files[MANIFEST] = (json.dumps(
manifest, ensure_ascii=False, sort_keys=True, indent=2,
) + '\n').encode()
files[MANIFEST] = (
json.dumps(
manifest,
ensure_ascii=False,
sort_keys=True,
indent=2,
)
+ "\n"
).encode()
verify_files(files)
output.mkdir(parents=True)
for name, content in sorted(files.items()):
target = output / 'repository' / name
target = output / "repository" / name
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(content)
archive_path = output / f'{ROOT_NAME}-{version}.zip'
with ZipFile(archive_path, 'w', compression=ZIP_DEFLATED) as archive:
archive_path = output / f"{ROOT_NAME}-{version}.zip"
with ZipFile(archive_path, "w", compression=ZIP_DEFLATED) as archive:
for name, content in sorted(files.items()):
item = ZipInfo(f'{ROOT_NAME}/{name}', (2020, 1, 1, 0, 0, 0))
item = ZipInfo(f"{ROOT_NAME}/{name}", (2020, 1, 1, 0, 0, 0))
item.create_system = 3
item.compress_type = ZIP_DEFLATED
item.external_attr = 0o100644 << 16
archive.writestr(item, content)
verify_package(archive_path)
if archive_path.stat().st_size > 16_000_000:
raise ValueError('AstrBot plugin ZIP exceeds 16 MB')
raise ValueError("AstrBot plugin ZIP exceeds 16 MB")
digest = hashlib.sha256(archive_path.read_bytes()).hexdigest()
archive_path.with_suffix('.zip.sha256').write_text(
f'{digest} {archive_path.name}\n', encoding='ascii',
archive_path.with_suffix(".zip.sha256").write_text(
f"{digest} {archive_path.name}\n",
encoding="ascii",
)
return archive_path
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--help-image', type=Path)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--help-image", type=Path)
args = parser.parse_args()
print(build(Path(__file__).resolve().parents[1], args.output,
help_image=args.help_image))
print(
build(
Path(__file__).resolve().parents[1],
args.output,
help_image=args.help_image,
)
)
if __name__ == '__main__':
if __name__ == "__main__":
main()
+5
View File
@@ -11,6 +11,7 @@ from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
from build_zhenxun import read_version, source_identity
from verify_nonebot import MANIFEST, ROOT_NAME, verify_files, verify_package
from help_image import build_help_image
from format_generated import format_generated
def build(root, output, *, help_image=None):
@@ -32,6 +33,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
@@ -64,6 +68,7 @@ def build(root, output, *, help_image=None):
}.items():
files[name] = (root / source).read_bytes()
files[prefix + "_version.py"] = f'__version__ = "{version}"\n'.encode()
format_generated(root, files)
for name, content in files.items():
if name.endswith(".py"):
ast.parse(content, filename=name, feature_version=(3, 10))
+5 -3
View File
@@ -43,7 +43,8 @@ foreach ($farmCandidate in $farmCandidates) {
$farmPrefix = $farmCandidate.Prefix
$farmProbe = 'import sys, importlib.util; sys.exit(' +
'sys.version_info < (3, 10) or ' +
'importlib.util.find_spec("PIL") is None)'
'importlib.util.find_spec("PIL") is None or ' +
'importlib.util.find_spec("ruff") is None)'
# Windows PowerShell 5.1 rewrites quotes in native arguments.
# Use stdin instead of -c; keep this script ASCII for PS 5.1.
$farmProbe | & $farmExe @farmPrefix -
@@ -56,8 +57,9 @@ foreach ($farmCandidate in $farmCandidates) {
}
}
if (-not $farmPython) {
Write-Error ('Python 3.10+ with Pillow not found. ' +
'Install Python/Pillow or set FARM_PYTHON.')
Write-Error ('Python 3.10+ with Pillow and Ruff not found. ' +
'Install Pillow and requirements-build.txt in the same Python, ' +
'or set FARM_PYTHON.')
exit 1
}
+12
View File
@@ -11,6 +11,7 @@ from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
from verify_zhenxun import MANIFEST, ROOT_NAME, verify_files, verify_package
from help_image import build_help_image
from format_generated import format_generated
def source_identity(root):
@@ -54,9 +55,19 @@ def build(root, output, *, help_image=None):
"requirements.txt", "config_migration.py",
):
files[name] = (integration / name).read_bytes()
# 真寻市场静态读取版本字段,生成时写入字面量而非运行时变量。
marker = b'"__FARM_VERSION__"'
if files['__init__.py'].count(marker) != 1:
raise ValueError('Zhenxun entry requires one version placeholder')
files['__init__.py'] = files['__init__.py'].replace(
marker, json.dumps(version).encode('ascii'),
)
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
@@ -87,6 +98,7 @@ def build(root, output, *, help_image=None):
}.items():
files[name] = source.read_bytes()
files["_version.py"] = f'__version__ = "{version}"\n'.encode()
format_generated(root, files)
for name, content in files.items():
if name.endswith(".py"):
ast.parse(content, filename=name, feature_version=(3, 10))
+1 -2
View File
@@ -8,8 +8,7 @@ from zipfile import ZipFile
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / 'src'))
from farm_core.catalogue_release import parse_manifest
from farm_render.catalogue_bundle import ROOTS
from farm_core.catalogue_release import ROOTS, parse_manifest
def check(root):
+17
View File
@@ -97,6 +97,8 @@ def main():
token = sync["steps"][-1]["env"]["FARM_RELEASE_TOKEN"]
if token != "${{ secrets.FARM_RELEASE_TOKEN }}":
raise ValueError("NoneBot must reuse FARM_RELEASE_TOKEN")
if '--release-tag' not in sync['steps'][-1]['run'].split():
raise ValueError('NoneBot sync must create its version release tag')
generated = yaml.load((
root / "release/nonebot/verify.yml"
).read_text(encoding="utf-8"), Loader=yaml.BaseLoader)
@@ -113,6 +115,21 @@ def main():
"github.repository == 'Shu-Ying/nonebot_plugin_farm'"
)):
raise ValueError("NoneBot PyPI boundary differs")
for workflow, job in ((generated, 'verify'), (publishing, 'build')):
steps = workflow['jobs'][job]['steps']
if not any(step.get('run') == (
'python -m build --sdist --wheel --outdir dist'
) for step in steps):
raise ValueError('NoneBot distributions must build inside dist')
upload = next(step for step in steps
if step.get('uses', '').startswith(
'actions/upload-artifact@'))
if (upload['with']['path'] != 'dist/*'
or upload['with'].get('if-no-files-found') != 'error'):
raise ValueError('NoneBot upload path must be dist/*')
if not any(step.get('run') == 'python -m twine check dist/*'
for step in publishing['jobs']['build']['steps']):
raise ValueError('NoneBot distribution check must use dist/*')
steps = publishing["jobs"]["publish"]["steps"]
if steps[-1]["with"]["password"] != "${{ secrets.PYPI_TOKEN }}":
raise ValueError("Keep the existing PyPI secret")
+70
View File
@@ -0,0 +1,70 @@
"""在生成清单前统一格式化内存中的发布代码,不修改开发源文件。"""
import importlib.util
import subprocess
import sys
import tempfile
from pathlib import Path, PurePosixPath
def format_generated(root, files):
"""仅整理导入和代码格式;成功后一次性更新文件集合。"""
root = Path(root).resolve()
if importlib.util.find_spec("ruff") is None:
raise RuntimeError(
"Ruff is required to build plugins. Install with: "
"python -m pip install -r requirements-build.txt"
)
selected = {}
for name, content in files.items():
if not name.endswith(".py"):
continue
path = PurePosixPath(name)
if (
path.is_absolute()
or "\\" in name
or ":" in name
or any(part in ("", ".", "..") for part in name.split("/"))
):
raise ValueError("Unsafe generated Python path")
selected[name] = content
if not selected:
return
with tempfile.TemporaryDirectory(prefix="farm-ruff-") as temporary:
staging = Path(temporary)
for name, content in selected.items():
target = staging / name
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(content)
common = ["--config", str(root / "pyproject.toml"), "--no-cache"]
for arguments in (
["check", "--select", "I", "--fix"],
["format"],
["check", "--select", "I"],
["format", "--check"],
):
result = subprocess.run(
[
sys.executable,
"-m",
"ruff",
*arguments,
*common,
str(staging),
],
cwd=root,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=60,
)
if result.returncode:
raise RuntimeError(
"Generated Python failed Ruff; install the pinned "
"requirements-build.txt dependencies.\n"
+ result.stdout
+ result.stderr
)
formatted = {name: (staging / name).read_bytes() for name in selected}
files.update(formatted)
+34 -4
View File
@@ -4,6 +4,7 @@ import argparse
import base64
import json
import os
import re
from urllib.error import HTTPError
from urllib.request import Request, urlopen
@@ -37,13 +38,19 @@ def github(method, path, payload, token, allow_missing=False):
) from None
def publish(package, confirmed=False, request=None):
def publish(package, confirmed=False, request=None, *, release_tag=False):
files = read_package(package)
manifest = verify_files(files)
if not re.fullmatch(r'[0-9]+\.[0-9]+\.[0-9]+(?:-[a-z0-9.-]+)?',
manifest['version']):
raise ValueError('Invalid release version in version.txt')
message = commit_message(manifest)
result = {"repository": REPOSITORY, "branch": BRANCH,
"version": manifest["version"], "message": message,
"published": False}
tag = 'v' + manifest['version']
if release_tag:
result.update(tag=tag, tag_created=False)
if not confirmed:
return result
if manifest["source"]["dirty"]:
@@ -53,10 +60,23 @@ def publish(package, confirmed=False, request=None):
raise ValueError("Configure FARM_RELEASE_TOKEN in a protected job")
call = request or (lambda method, path, data: github(
method, path, data, token,
allow_missing=path.startswith(("git/ref/heads/", "contents/")),
allow_missing=path.startswith((
"git/ref/heads/", "git/ref/tags/", "contents/",
)),
))
if not call("GET", "", None):
raise ValueError(f"Target repository is not accessible: {REPOSITORY}")
if release_tag and call('GET', f'git/ref/tags/{tag}', None):
owned = call('GET', f'contents/FARM-MANIFEST.json?ref={tag}', None)
if owned is None or owned.get('encoding') != 'base64':
raise ValueError('Existing release is not managed; bump version.txt')
previous = json.loads(base64.b64decode(owned['content']))
if (previous.get('target') != 'nonebot'
or previous.get('plugin') != manifest['plugin']
or previous.get('version') != manifest['version']
or previous.get('files') != manifest['files']):
raise ValueError('Release tag has different files; bump version.txt')
return {**result, 'tag_reused': True}
ref = call("GET", f"git/ref/heads/{BRANCH}", None)
parent = ref["object"]["sha"] if ref else None
if parent:
@@ -92,15 +112,25 @@ def publish(package, confirmed=False, request=None):
call("POST", "git/refs", {
"ref": f"refs/heads/{BRANCH}", "sha": commit["sha"],
})
return {**result, "published": True, "commit": commit["sha"]}
result.update(published=True, commit=commit['sha'])
if release_tag:
# 仅创建不可变标签;失败保留已同步main,重跑时重新检查,不移动旧标签。
call('POST', 'git/refs', {
'ref': f'refs/tags/{tag}', 'sha': commit['sha'],
})
result['tag_created'] = True
return result
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("package")
parser.add_argument("--publish", action="store_true")
parser.add_argument('--release-tag', action='store_true',
help='同步后创建v版本标签,触发目标仓库PyPI发布')
args = parser.parse_args()
print(json.dumps(publish(args.package, args.publish), ensure_ascii=False))
print(json.dumps(publish(args.package, args.publish,
release_tag=args.release_tag), ensure_ascii=False))
if __name__ == "__main__":
+33 -1
View File
@@ -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',
+11
View File
@@ -1,6 +1,7 @@
"""独立验证生成的真寻包;仅标准库,不导入或启动插件。"""
import argparse
import ast
import hashlib
import json
from pathlib import Path, PurePosixPath
@@ -45,6 +46,16 @@ def verify_files(files):
raise ValueError('Resource ZIP must not be in plugin repository')
if not required <= set(files):
raise ValueError("Incomplete plugin")
tree = ast.parse(files['__init__.py'])
versions = [
keyword.value for node in ast.walk(tree)
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name)
and node.func.id == 'PluginExtraData'
for keyword in node.keywords if keyword.arg == 'version'
]
if (len(versions) != 1 or not isinstance(versions[0], ast.Constant)
or versions[0].value != manifest['version']):
raise ValueError('PluginExtraData requires matching literal version')
return manifest
+6
View File
@@ -15,6 +15,12 @@ FILES = {
MANIFEST_LIMIT = 16 * 1024
DIGEST = re.compile(r'[0-9a-f]{64}')
VERSION = re.compile(r'[A-Za-z0-9._-]{1,64}')
ROOTS = {
'Github': 'https://raw.githubusercontent.com/Shu-Ying/'
'farm_service/main/catalogue',
'Gitea': 'https://gitea.diuse.work/Diuse/farm_service/'
'raw/branch/main/catalogue',
}
def _pairs(items):
+3 -3
View File
@@ -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__,
)
+99 -15
View File
@@ -5,6 +5,7 @@ from importlib import resources
import json
import logging
from pathlib import Path
import sys
import tempfile
import threading
import time
@@ -41,23 +42,104 @@ def checked_url(url):
return url
class _DownloadProgress:
"""仅在交互终端原位刷新;重定向输出不写控制字符。"""
def __init__(self, name):
self.name = name
self.stream = sys.stderr
self.enabled = bool(getattr(self.stream, 'isatty', lambda: False)())
self.last_report = float('-inf')
self.progress = None
self.task = None
def update(self, total, length, now, *, force=False):
if not self.enabled or (not force and now-self.last_report < 0.2):
return
try:
if self.progress is None:
from rich.console import Console
from rich.progress import (
BarColumn, DownloadColumn, Progress, TextColumn,
TimeRemainingColumn, TransferSpeedColumn,
)
console = Console(file=self.stream)
if not console.is_terminal:
self.enabled = False
return
self.progress = Progress(
TextColumn('{task.description}', markup=False),
BarColumn(bar_width=None),
TextColumn('{task.fields[percentage]}'),
DownloadColumn(), TransferSpeedColumn(),
TextColumn('eta'), TimeRemainingColumn(),
console=console, auto_refresh=False, expand=True,
redirect_stdout=False, redirect_stderr=False,
)
self.task = self.progress.add_task(
f'[农场] {self.name}', total=length if length > 0 else None,
percentage='',
)
self.progress.start()
percentage = f'{min(1, total / length):.1%}' if length > 0 else ''
self.progress.update(self.task, completed=total,
percentage=percentage)
self.progress.refresh()
except Exception:
# 终端或显示库故障不影响资源下载,也不接管宿主输出流。
self.close()
self.enabled = False
self.last_report = now
def close(self):
progress, self.progress = self.progress, None
self.task = None
if progress is not None:
try:
progress.stop()
except Exception:
pass
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)
progress = _DownloadProgress(name)
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
progress.update(total, length, started)
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)
progress.update(total, length, now)
progress.update(total, length, started, force=True)
progress.close()
logger.info('农场下载完成:%s 共 %.2f MiB(待校验)',
name, total / 1048576)
except Exception as error:
progress.close()
logger.warning('农场下载失败:%s(%s)', name, type(error).__name__)
raise
def verify_bundle(path, manifest):
@@ -93,7 +175,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)
+5 -7
View File
@@ -11,7 +11,7 @@ import uuid
from zipfile import BadZipFile
from farm_core.catalogue_release import (
FILES, MANIFEST_LIMIT, canonical, check_file, decode, load_catalogue,
FILES, MANIFEST_LIMIT, ROOTS, canonical, check_file, decode, load_catalogue,
parse_manifest,
)
from farm_core.domain import FarmError
@@ -23,12 +23,6 @@ from .release_validation import validate_archive, validate_metadata
logger = logging.getLogger(__name__)
_LOCK = threading.RLock()
_FAILURES = (OSError, ValueError, FarmError, EOFError, BadZipFile)
ROOTS = {
'Github': 'https://raw.githubusercontent.com/Shu-Ying/'
'farm_service/main/catalogue',
'Gitea': 'https://gitea.diuse.work/Diuse/farm_service/'
'raw/branch/main/catalogue',
}
@dataclass(frozen=True)
@@ -146,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,
@@ -180,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)
@@ -192,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']
+4
View File
@@ -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
+154 -2
View File
@@ -1,9 +1,14 @@
from concurrent.futures import ThreadPoolExecutor
from io import BytesIO
from io import BytesIO, StringIO
from itertools import count
from pathlib import Path
import re
import subprocess
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
from unittest.mock import Mock, patch
from farm_render import bundle
@@ -12,6 +17,15 @@ ZIP = ROOT / 'src/farm_render/data/assets.zip'
class BundleTests(unittest.TestCase):
def test_manifest_check_without_third_party_packages(self):
result = subprocess.run(
[sys.executable, '-S',
str(ROOT / 'scripts/check_asset_manifest.py')],
cwd=ROOT, capture_output=True, text=True, timeout=30,
)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertIn('Verified external asset manifest', result.stdout)
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
@@ -91,3 +105,141 @@ 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_refreshes_terminal_progress_on_same_line(self):
from rich.console import Console
from rich.progress import Progress
class Response(BytesIO):
headers = {'Content-Length': '262144'}
def geturl(self):
return 'https://farm.example/private/assets.zip'
terminal = StringIO()
terminal.isatty = lambda: True
progress_clock = count()
with patch.dict('os.environ', {'TERM': 'xterm-256color'}), \
patch.object(bundle.sys, 'stderr', terminal), \
patch('rich.console.Console', side_effect=lambda **kwargs:
Console(**kwargs, force_terminal=True,
force_interactive=True, legacy_windows=False,
color_system='standard', width=120)), \
patch('rich.progress.Progress', side_effect=lambda *args,
**kwargs: Progress(*args, **kwargs,
get_time=lambda: next(progress_clock))), \
patch.object(bundle, 'urlopen', return_value=Response(
b'x' * 262144)), patch.object(
bundle, 'time', SimpleNamespace(monotonic=Mock(
side_effect=[0, 1, 2, 10, 11]))), \
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('下载完成', messages)
self.assertNotIn('下载进度', messages)
raw = terminal.getvalue()
progress = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]', '', raw)
self.assertIn('0.0%', progress)
self.assertIn('50.0%', progress)
self.assertIn('100.0%', progress)
self.assertIn('eta', progress)
self.assertIn('kB', progress)
self.assertIn('/s', progress)
self.assertGreater(raw.count('\r'), 2)
self.assertIn('\x1b[2K', raw)
self.assertNotIn('farm.example', progress)
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'
output = StringIO()
with patch.object(bundle.sys, 'stderr', output), \
patch.object(bundle, 'urlopen', return_value=Response(b'abc')), \
patch.object(bundle, 'time', SimpleNamespace(
monotonic=Mock(side_effect=[0, 10]))), \
self.assertLogs(bundle.logger, level='INFO') as captured:
bundle._copy_remote('https://farm.example/a', BytesIO(), 10)
self.assertIn('下载完成', '\n'.join(captured.output))
self.assertEqual(output.getvalue(), '')
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)
def test_terminal_progress_is_throttled_and_unknown_size_has_no_percent(self):
terminal = StringIO()
terminal.isatty = lambda: True
with patch.object(bundle.sys, 'stderr', terminal), \
patch('rich.progress.Progress') as rich_progress:
progress = bundle._DownloadProgress('字体包')
progress.update(0, 0, 0)
progress.update(65536, 0, 0.1)
progress.update(131072, 0, 0.2)
progress.close()
display = rich_progress.return_value
self.assertEqual(display.update.call_count, 2)
self.assertEqual(display.update.call_args.kwargs['completed'], 131072)
self.assertEqual(display.update.call_args.kwargs['percentage'], '')
self.assertIsNone(display.add_task.call_args.kwargs['total'])
self.assertFalse(rich_progress.call_args.kwargs['auto_refresh'])
self.assertFalse(rich_progress.call_args.kwargs['redirect_stdout'])
self.assertFalse(rich_progress.call_args.kwargs['redirect_stderr'])
display.stop.assert_called_once()
def test_download_failure_ends_progress_line(self):
from rich.console import Console
class Response(BytesIO):
headers = {'Content-Length': '10'}
def geturl(self):
return 'https://farm.example/a'
def read(self, size):
raise OSError('private error')
terminal = StringIO()
terminal.isatty = lambda: True
with patch.dict('os.environ', {'TERM': 'xterm-256color'}), \
patch.object(bundle.sys, 'stderr', terminal), \
patch('rich.console.Console', side_effect=lambda **kwargs:
Console(**kwargs, force_terminal=True,
force_interactive=True, legacy_windows=False,
color_system='standard', width=120)), \
patch.object(bundle, 'urlopen', return_value=Response()), \
self.assertLogs(bundle.logger, level='INFO') as captured:
with self.assertRaises(OSError):
bundle._copy_remote('https://farm.example/a', BytesIO(), 10)
raw = terminal.getvalue()
progress = re.sub(r'\x1b\[[0-9;?]*[a-zA-Z]', '', raw)
self.assertTrue(progress.endswith('\n'), repr(raw))
self.assertNotIn('100.0%', progress)
self.assertNotIn('下载完成', '\n'.join(captured.output))
def test_progress_display_failure_does_not_abort_download(self):
class Response(BytesIO):
headers = {'Content-Length': '3'}
def geturl(self):
return 'https://farm.example/a'
terminal = StringIO()
terminal.isatty = lambda: True
destination = BytesIO()
with patch.object(bundle.sys, 'stderr', terminal), \
patch.object(bundle, 'urlopen', return_value=Response(b'abc')), \
patch('rich.progress.Progress') as rich_progress:
display = rich_progress.return_value
display.refresh.side_effect = OSError('broken terminal')
bundle._copy_remote('https://farm.example/a', destination, 10)
self.assertEqual(destination.getvalue(), b'abc')
display.stop.assert_called_once()
+8 -1
View File
@@ -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',
+40 -6
View File
@@ -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]
@@ -32,7 +32,7 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(manifest['version'], version)
self.assertEqual(json.loads(files['metadata.yaml'])['version'], version)
metadata = json.loads(files['metadata.yaml'])
self.assertEqual(metadata['display_name'], '农场')
self.assertEqual(metadata['display_name'], '真寻农场')
self.assertEqual(metadata['support_platforms'], ['aiocqhttp'])
self.assertTrue(metadata['short_desc'])
self.assertEqual(files['logo.png'],
@@ -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 = {}
@@ -129,6 +154,7 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
self.assertEqual(plugin.runtime.data_dir,
(self.root / 'existing-custom-data').resolve())
self.assertIsNone(plugin.runtime.font_path)
self.assertEqual(plugin.runtime.resource_source, 'Github')
# 使用真实开发素材预置缓存,模拟宿主测试不访问网络。
manifest = json.loads((ROOT /
'src/farm_render/data/asset-manifest.json').read_bytes())
@@ -152,6 +178,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:
+48
View File
@@ -0,0 +1,48 @@
import ast
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))
from format_generated import format_generated
class FormatGeneratedTests(unittest.TestCase):
def test_formats_sorts_and_is_idempotent_without_changing_assets(self):
files = {
"package/main.py": (
b"import sys\r\nimport os\r\n\r\n"
b"def run( ):\r\n return 'ok',os.name,sys.version\r\n"
),
"images/example.png": b"unchanged binary\x00",
}
format_generated(ROOT, files)
source = files["package/main.py"]
self.assertTrue(source.startswith(b"import os\nimport sys\n"))
self.assertIn(b' return "ok", os.name, sys.version\n', source)
self.assertNotIn(b"\r", source)
ast.parse(source, feature_version=(3, 10))
self.assertEqual(files["images/example.png"], b"unchanged binary\x00")
first = files.copy()
format_generated(ROOT, files)
self.assertEqual(files, first)
def test_ruff_failure_does_not_partially_update_file_collection(self):
files = {"good.py": b"x=1\n", "bad.py": b"def broken(:\n"}
before = files.copy()
with self.assertRaisesRegex(RuntimeError, "failed Ruff"):
format_generated(ROOT, files)
self.assertEqual(files, before)
def test_rejects_unsafe_paths_and_missing_ruff(self):
for name in ("../outside.py", "C:/outside.py", "/outside.py"):
with self.subTest(name=name):
with self.assertRaisesRegex(ValueError, "Unsafe"):
format_generated(ROOT, {name: b"x=1\n"})
with patch(
"format_generated.importlib.util.find_spec", return_value=None
):
with self.assertRaisesRegex(RuntimeError, "requirements-build"):
format_generated(ROOT, {"main.py": b"x=1\n"})
+62 -2
View File
@@ -25,9 +25,13 @@ class NoneBotPackageTests(unittest.TestCase):
def test_reproducible_standard_package_and_shared_runtime(self):
other = build(ROOT, self.root / "second")
self.assertEqual(self.bundle.read_bytes(), other.read_bytes())
self.assertEqual(self.files["nonebot_plugin_farm/runtime.py"], (
from format_generated import format_generated
expected = {"nonebot_plugin_farm/runtime.py": (
ROOT / "integrations/shared/runtime.py"
).read_bytes())
).read_bytes()}
format_generated(ROOT, expected)
self.assertEqual(self.files["nonebot_plugin_farm/runtime.py"],
expected["nonebot_plugin_farm/runtime.py"])
for suffix in ("farm_core/data/plants.json",
"farm_render/data/asset-manifest.json",
"farm_render/data/font-manifest.json",
@@ -165,3 +169,59 @@ class NoneBotPackageTests(unittest.TestCase):
with self.assertRaises((ValueError, RuntimeError)):
publish(bundle, True, request)
self.assertFalse(any(method == "PATCH" for method, _, _ in calls))
def test_release_tag_is_created_after_main_sync(self):
bundle, manifest = self.clean_package()
calls = []
def request(method, path, data):
calls.append((method, path, data))
if path == '':
return {'default_branch': 'main'}
if path.startswith('git/ref/tags/'):
return None
if path.startswith('git/ref/heads/'):
return {'object': {'sha': 'existing'}}
if path.startswith('contents/'):
return None
return {'sha': 'generated'}
with patch.dict('os.environ', {'FARM_RELEASE_TOKEN': 'test'}):
result = publish(bundle, True, request, release_tag=True)
self.assertTrue(result['tag_created'])
self.assertEqual(calls[-1], ('POST', 'git/refs', {
'ref': 'refs/tags/v' + manifest['version'], 'sha': 'generated',
}))
self.assertLess(next(i for i, call in enumerate(calls)
if call[0] == 'PATCH'), len(calls) - 1)
def test_existing_release_tag_is_reused_or_conflict_blocks_all_writes(self):
bundle, manifest = self.clean_package()
for matching in (True, False):
calls = []
previous = json.loads(json.dumps(manifest))
if not matching:
previous['files']['README.md'] = '0' * 64
def request(method, path, data):
calls.append((method, path, data))
if path == '':
return {'default_branch': 'main'}
if path.startswith('git/ref/tags/'):
return {'object': {'sha': 'released'}}
if path.startswith('contents/'):
return {'encoding': 'base64', 'content': base64.b64encode(
json.dumps(previous).encode(),
).decode()}
self.fail('Unexpected request: ' + path)
with patch.dict('os.environ', {'FARM_RELEASE_TOKEN': 'test'}):
if matching:
result = publish(bundle, True, request, release_tag=True)
self.assertFalse(result['tag_created'])
else:
with self.assertRaisesRegex(ValueError, 'version.txt'):
publish(bundle, True, request, release_tag=True)
self.assertTrue(all(method == 'GET' for method, _, _ in calls))
def test_release_tag_dry_run_does_not_access_remote(self):
result = publish(self.bundle, release_tag=True,
request=lambda *args: self.fail('Network in dry run'))
self.assertEqual(result['tag'], 'v' + result['version'])
self.assertFalse(result['tag_created'])
+5
View File
@@ -157,6 +157,11 @@ class ZhenxunPackageTests(unittest.TestCase):
self.assertEqual(archive.read(
"zhenxun_plugin_farm_preview/_version.py",
), b'__version__ = "1.2.3-preview.4"\n')
entry = archive.read(
'zhenxun_plugin_farm_preview/__init__.py',
).decode()
self.assertIn('version="1.2.3-preview.4"', entry)
self.assertNotIn('__version__', entry)
def clean_package(self):
from verify_zhenxun import read_package, MANIFEST, ROOT_NAME
+39
View File
@@ -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
View File
@@ -1 +1 @@
2.0.3
2.0.6