mirror of
https://github.com/Shu-Ying/main_plugin_farm.git
synced 2026-11-14 17:00:07 +08:00
Compare commits
7
Commits
e7c6eac07f
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e897bad506 | ||
|
|
b851e490be | ||
|
|
8872ffe3be | ||
|
|
b09d51c7c9 | ||
|
|
c6b504924e | ||
|
|
d05b2e5765 | ||
|
|
b3455ded58 |
@@ -33,7 +33,7 @@ jobs:
|
|||||||
- run: >-
|
- run: >-
|
||||||
python -m pip install "Pillow>=10.4,<13"
|
python -m pip install "Pillow>=10.4,<13"
|
||||||
setuptools wheel "PyYAML>=6,<7" build
|
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/ci_font.py
|
||||||
- run: python scripts/check_workflows.py
|
- run: python scripts/check_workflows.py
|
||||||
- run: python scripts/verify_foundation.py
|
- run: python scripts/verify_foundation.py
|
||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
run: python scripts/check_asset_manifest.py
|
run: python scripts/check_asset_manifest.py
|
||||||
- name: Install help rendering dependencies
|
- name: Install help rendering dependencies
|
||||||
run: |
|
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 update
|
||||||
sudo apt-get install -y fonts-noto-cjk
|
sudo apt-get install -y fonts-noto-cjk
|
||||||
- run: python scripts/ci_font.py
|
- run: python scripts/ci_font.py
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
publish:
|
publish:
|
||||||
description: Sync main branch in NoneBot repository
|
description: Sync NoneBot repository and trigger PyPI version release
|
||||||
default: false
|
default: false
|
||||||
type: boolean
|
type: boolean
|
||||||
permissions:
|
permissions:
|
||||||
@@ -33,7 +33,7 @@ jobs:
|
|||||||
- run: >-
|
- run: >-
|
||||||
python -m pip install "Pillow>=10.4,<13"
|
python -m pip install "Pillow>=10.4,<13"
|
||||||
setuptools wheel "PyYAML>=6,<7" build
|
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/ci_font.py
|
||||||
- run: python scripts/check_workflows.py
|
- run: python scripts/check_workflows.py
|
||||||
- run: python scripts/verify_foundation.py
|
- run: python scripts/verify_foundation.py
|
||||||
@@ -51,7 +51,7 @@ jobs:
|
|||||||
run: python scripts/check_asset_manifest.py
|
run: python scripts/check_asset_manifest.py
|
||||||
- name: Install help rendering dependencies
|
- name: Install help rendering dependencies
|
||||||
run: |
|
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 update
|
||||||
sudo apt-get install -y fonts-noto-cjk
|
sudo apt-get install -y fonts-noto-cjk
|
||||||
- run: python scripts/ci_font.py
|
- run: python scripts/ci_font.py
|
||||||
@@ -102,9 +102,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
name: nonebot-preview
|
name: nonebot-preview
|
||||||
path: incoming
|
path: incoming
|
||||||
- name: Sync validated NoneBot repository
|
- name: Sync NoneBot repository and trigger version release
|
||||||
env:
|
env:
|
||||||
FARM_RELEASE_TOKEN: ${{ secrets.FARM_RELEASE_TOKEN }}
|
FARM_RELEASE_TOKEN: ${{ secrets.FARM_RELEASE_TOKEN }}
|
||||||
run: >-
|
run: >-
|
||||||
python scripts/publish_nonebot.py
|
python scripts/publish_nonebot.py
|
||||||
incoming/nonebot_plugin_farm-*.zip --publish
|
incoming/nonebot_plugin_farm-*.zip --publish --release-tag
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
run: >-
|
run: >-
|
||||||
python -m pip install "Pillow==$PILLOW_VERSION"
|
python -m pip install "Pillow==$PILLOW_VERSION"
|
||||||
setuptools wheel "PyYAML>=6,<7"
|
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/ci_font.py
|
||||||
- run: python scripts/check_workflows.py
|
- run: python scripts/check_workflows.py
|
||||||
- run: python scripts/verify_foundation.py
|
- run: python scripts/verify_foundation.py
|
||||||
@@ -61,7 +61,7 @@ jobs:
|
|||||||
run: python scripts/check_asset_manifest.py
|
run: python scripts/check_asset_manifest.py
|
||||||
- name: Install help rendering dependencies
|
- name: Install help rendering dependencies
|
||||||
run: |
|
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 update
|
||||||
sudo apt-get install -y fonts-noto-cjk
|
sudo apt-get install -y fonts-noto-cjk
|
||||||
- run: python scripts/ci_font.py
|
- run: python scripts/ci_font.py
|
||||||
|
|||||||
@@ -38,6 +38,24 @@
|
|||||||
|
|
||||||
`如果没有你需要的框架,那么请在本仓库提交issue吧,Codex会尽量迁移的`
|
`如果没有你需要的框架,那么请在本仓库提交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`许可证进行开源
|
本项目采用的是`GPLv3`许可证进行开源
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
| 文档 | 用途 |
|
| 文档 | 用途 |
|
||||||
|---|---|
|
|---|---|
|
||||||
|
| [nonebot-release.md](nonebot-release.md) | NoneBot 同步、自动版本标签、PyPI发布及失败重跑 |
|
||||||
| [catalogue-updates.md](catalogue-updates.md) | Service 作物维护、发布、启动更新、缓存与兼容回滚 |
|
| [catalogue-updates.md](catalogue-updates.md) | Service 作物维护、发布、启动更新、缓存与兼容回滚 |
|
||||||
| [23-seasonal-events-and-rust-service.md](23-seasonal-events-and-rust-service.md) | 节日活动交互备选、Rust 发布中心、同步、资产事务、过期清理与上线规划(草案) |
|
| [23-seasonal-events-and-rust-service.md](23-seasonal-events-and-rust-service.md) | 节日活动交互备选、Rust 发布中心、同步、资产事务、过期清理与上线规划(草案) |
|
||||||
| [09-foundation.md](09-foundation.md) | 已实现的第一批核心、API、测试与限制 |
|
| [09-foundation.md](09-foundation.md) | 已实现的第一批核心、API、测试与限制 |
|
||||||
|
|||||||
@@ -31,7 +31,19 @@
|
|||||||
|
|
||||||
启动会核对清单、大小、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控制台或日志页查看,
|
||||||
|
插件不创建独立日志文件,也不修改宿主日志级别或处理器。
|
||||||
|
|
||||||
|
终端同时显示启动阶段、缓存复用、下载开始、完成及回退信息。交互终端中的
|
||||||
|
Rich 下载进度条在同一行刷新,最多每0.2秒一次;显示已下载量和下载速度,
|
||||||
|
服务器提供文件总大小时还显示百分比、总大小和预计剩余时间。
|
||||||
|
未提供总大小时不显示百分比和预计剩余时间。网页日志页、重定向输出和日志文件
|
||||||
|
不记录中间进度,避免刷屏或混入控制字符。文件下载完成后仍需校验,只有
|
||||||
|
整套校验通过才切换版本。插件优先
|
||||||
|
复用宿主已有的 INFO 终端日志通道;真寻和NoneBot没有时添加一个并在关闭时
|
||||||
|
移除。AstrBot普通日志使用 `astrbot.api.logger`,输出级别和位置由宿主管理;
|
||||||
|
进度条只写入可原位刷新的交互终端,不进入宿主日志。
|
||||||
|
|
||||||
备份应包含完整数据目录及其 `catalogue` 缓存。玩家库存或地块若引用基线没有的
|
备份应包含完整数据目录及其 `catalogue` 缓存。玩家库存或地块若引用基线没有的
|
||||||
新作物 ID,而可用缓存和远端均不能提供这些 ID,插件会明确拒绝启动,避免
|
新作物 ID,而可用缓存和远端均不能提供这些 ID,插件会明确拒绝启动,避免
|
||||||
|
|||||||
@@ -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。
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
Pillow>=10.4
|
Pillow>=10.4
|
||||||
aiohttp>=3.9,<4
|
aiohttp>=3.9,<4
|
||||||
|
rich>=13.9,<16
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
from nonebot.plugin import PluginMetadata
|
from nonebot.plugin import PluginMetadata
|
||||||
from zhenxun.configs.utils import Command, PluginExtraData, RegisterConfig
|
from zhenxun.configs.utils import Command, PluginExtraData, RegisterConfig
|
||||||
|
|
||||||
from ._version import __version__
|
|
||||||
from .runtime import COMMANDS, HELP
|
from .runtime import COMMANDS, HELP
|
||||||
|
|
||||||
__plugin_meta__ = PluginMetadata(
|
__plugin_meta__ = PluginMetadata(
|
||||||
@@ -12,7 +11,7 @@ __plugin_meta__ = PluginMetadata(
|
|||||||
homepage="https://github.com/Shu-Ying/zhenxun_plugin_farm",
|
homepage="https://github.com/Shu-Ying/zhenxun_plugin_farm",
|
||||||
supported_adapters={"~onebot.v11", "~qq"},
|
supported_adapters={"~onebot.v11", "~qq"},
|
||||||
extra=PluginExtraData(
|
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],
|
commands=[Command(command=name) for name in COMMANDS],
|
||||||
configs=[RegisterConfig(
|
configs=[RegisterConfig(
|
||||||
key="农场图片质量", value=75, default_value=75, type=int,
|
key="农场图片质量", value=75, default_value=75, type=int,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
# 使用宿主已有的NoneBot/真寻/Alconna/Uninfo,不重复安装宿主。
|
# 使用宿主已有的NoneBot/真寻/Alconna/Uninfo,不重复安装宿主。
|
||||||
# 与真寻当前pillow<11的约束取交集,不升级到12。
|
# 与真寻当前pillow<11的约束取交集,不升级到12。
|
||||||
Pillow>=10.4,<11
|
Pillow>=10.4,<11
|
||||||
|
rich>=13.9,<16
|
||||||
|
|||||||
+8
-1
@@ -12,7 +12,7 @@ license = {file = "LICENSE"}
|
|||||||
dependencies = []
|
dependencies = []
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
render = ["Pillow>=10.4,<13"]
|
render = ["Pillow>=10.4,<13", "rich>=13.9,<16"]
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["src"]
|
where = ["src"]
|
||||||
@@ -25,6 +25,13 @@ farm_core = ["data/*.json"]
|
|||||||
farm_render = ["data/*.json", "data/*.zip", "data/*.md", "data/*.txt"]
|
farm_render = ["data/*.json", "data/*.zip", "data/*.md", "data/*.txt"]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
|
required-version = "==0.8.6"
|
||||||
line-length = 80
|
line-length = 80
|
||||||
target-version = "py310"
|
target-version = "py310"
|
||||||
|
indent-width = 4
|
||||||
extend-exclude = ["legacy", ".agents", ".comet"]
|
extend-exclude = ["legacy", ".agents", ".comet"]
|
||||||
|
|
||||||
|
[tool.ruff.format]
|
||||||
|
quote-style = "double"
|
||||||
|
indent-style = "space"
|
||||||
|
line-ending = "lf"
|
||||||
|
|||||||
+2
-2
@@ -49,9 +49,9 @@
|
|||||||
---
|
---
|
||||||
|
|
||||||
## 如何安装
|
## 如何安装
|
||||||
|
在Astrbot的插件商店下载即可
|
||||||
|
|
||||||
方法一(推荐):在小真寻后台的插件商店下载即可<br>
|
`在安装后,每次启动均会向`[farm_service](https://github.com/Shu-Ying/farm_service)`仓库进行下载资源,默认使用Github进行下载,可以在设置中切换为Gitea镜像`
|
||||||
方法二:下载源码放在小真寻`plugin`目录下
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+3
-16
@@ -50,24 +50,11 @@
|
|||||||
|
|
||||||
## 如何安装
|
## 如何安装
|
||||||
|
|
||||||
使用真寻分发仓库的完整生成内容或Actions中的真寻预览包,不能直接
|
方法一(推荐):在小真寻后台的插件商店下载即可
|
||||||
复制主仓库的integrations目录。先停止机器人,备份代码与数据,停用
|
|
||||||
会注册同名命令的旧插件,再在宿主环境安装本插件requirements.txt。
|
|
||||||
Pillow要求>=10.4,<11,不强制升级宿主到12。安装后手工启动并检查日志。
|
|
||||||
|
|
||||||
默认数据目录为真寻DATA_PATH下的farm_db,新库为farm.sqlite3,日志为
|
方法二:下载源码放在小真寻`plugin`目录下
|
||||||
farm-preview.log;旧farm.db不得直接改名覆盖。资源源默认Github,可在
|
|
||||||
网页配置选择Gitea/farm_service;服务地址独立保留,修改资源源后重载。
|
|
||||||
图片质量默认75、整数1~95,仅“我的农场 高清”支持高清且质量至少85。
|
|
||||||
资源或字体下载失败时文字业务仍可用,详细原因见日志。
|
|
||||||
|
|
||||||
升级前停机备份完整代码与数据目录;安装器拒绝覆盖,先移走旧目录。
|
`在安装后,每次启动均会向`[farm_service](https://github.com/Shu-Ying/farm_service)`仓库进行下载资源,默认使用Github进行下载,可以在设置中切换为Gitea镜像`
|
||||||
回退需匹配代码和数据库备份,不让旧代码强行打开新schema;恢复旧数据
|
|
||||||
会丢失备份后进度。卸载时停止加载并移走插件代码,保留数据供恢复。
|
|
||||||
|
|
||||||
开发者构建、包验证、安全安装、配置、备份卸载与Secrets详见
|
|
||||||
[当前维护说明](https://github.com/Shu-Ying/main_plugin_farm/blob/main/docs/zhenxun-preview-current.md)。
|
|
||||||
本地离线验证不代表真实真寻宿主、公开资源或远端发布已验证。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -31,12 +31,12 @@ jobs:
|
|||||||
raise SystemExit('Tag differs from version.txt')
|
raise SystemExit('Tag differs from version.txt')
|
||||||
PY
|
PY
|
||||||
- run: python -m pip install build twine
|
- run: python -m pip install build twine
|
||||||
- run: python -m build --sdist --wheel --outdir ../packages
|
- run: python -m build --sdist --wheel --outdir dist
|
||||||
- run: python -m twine check ../packages/*
|
- run: python -m twine check dist/*
|
||||||
- uses: actions/upload-artifact@v7
|
- uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: pypi-distributions
|
name: pypi-distributions
|
||||||
path: ../packages/*
|
path: dist/*
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
publish:
|
publish:
|
||||||
needs: build
|
needs: build
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ dependencies = [
|
|||||||
"nonebot-plugin-uninfo>=0.6,<1",
|
"nonebot-plugin-uninfo>=0.6,<1",
|
||||||
"nonebot-plugin-localstore>=0.7,<1",
|
"nonebot-plugin-localstore>=0.7,<1",
|
||||||
"Pillow>=10.4,<13",
|
"Pillow>=10.4,<13",
|
||||||
|
"rich>=13.9,<16",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.urls]
|
[project.urls]
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ jobs:
|
|||||||
python-version: '3.10'
|
python-version: '3.10'
|
||||||
- run: python verify_package.py --directory .
|
- run: python verify_package.py --directory .
|
||||||
- run: python -m pip install build
|
- 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
|
- uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: nonebot-distributions
|
name: nonebot-distributions
|
||||||
path: ../packages/*
|
path: dist/*
|
||||||
if-no-files-found: error
|
if-no-files-found: error
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
# Keep in sync with tool.ruff.required-version in pyproject.toml.
|
||||||
|
ruff==0.8.6
|
||||||
+153
-72
@@ -5,125 +5,206 @@ import ast
|
|||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
import runpy
|
import runpy
|
||||||
from pathlib import Path
|
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
|
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_astrbot import MANIFEST, ROOT_NAME, verify_files, verify_package
|
||||||
from verify_zhenxun import read_package as read_shared
|
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):
|
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 -->"
|
||||||
if marker not in template:
|
if marker not in template:
|
||||||
if './images/help-astrbot.png' not in template:
|
if "./images/help-astrbot.png" not in template:
|
||||||
raise ValueError('AstrBot README requires its help menu image')
|
raise ValueError("AstrBot README requires its help menu image")
|
||||||
return template.encode('utf-8')
|
return template.encode("utf-8")
|
||||||
if template.count(marker) != 1:
|
if template.count(marker) != 1:
|
||||||
raise ValueError('AstrBot README requires one command marker')
|
raise ValueError("AstrBot README requires one command marker")
|
||||||
definitions = runpy.run_path(str(root / 'src/farm_core/commands.py'))
|
definitions = runpy.run_path(str(root / "src/farm_core/commands.py"))
|
||||||
lines = ['| 分类 | 指令 | 说明 |', '| --- | --- | --- |']
|
lines = ["| 分类 | 指令 | 说明 |", "| --- | --- | --- |"]
|
||||||
specs = definitions['command_specs']('astrbot')
|
specs = definitions["command_specs"]("astrbot")
|
||||||
for group, rows in definitions['help_groups'](specs):
|
for group, rows in definitions["help_groups"](specs):
|
||||||
for usage, description in rows:
|
for usage, description in rows:
|
||||||
values = (group, f'`/{usage}`', description)
|
values = (group, f"`/{usage}`", description)
|
||||||
lines.append('| ' + ' | '.join(
|
lines.append(
|
||||||
value.replace('|', r'\|').replace('\n', ' ')
|
"| "
|
||||||
for value in values
|
+ " | ".join(
|
||||||
) + ' |')
|
value.replace("|", r"\|").replace("\n", " ")
|
||||||
return template.replace(marker, '\n'.join(lines)).encode('utf-8')
|
for value in values
|
||||||
|
)
|
||||||
|
+ " |"
|
||||||
|
)
|
||||||
|
return template.replace(marker, "\n".join(lines)).encode("utf-8")
|
||||||
|
|
||||||
|
|
||||||
def build(root, output, *, help_image=None):
|
def build(root, output, *, help_image=None):
|
||||||
root, output = Path(root).resolve(), Path(output).resolve()
|
root, output = Path(root).resolve(), Path(output).resolve()
|
||||||
version = read_version(root)
|
version = read_version(root)
|
||||||
if output.exists():
|
if output.exists():
|
||||||
raise FileExistsError('Use a new output directory')
|
raise FileExistsError("Use a new output directory")
|
||||||
if output.is_relative_to(root) and not output.is_relative_to(root / 'dist'):
|
if output.is_relative_to(root) and not output.is_relative_to(root / "dist"):
|
||||||
raise ValueError('In-project output must be inside dist')
|
raise ValueError("In-project output must be inside dist")
|
||||||
with tempfile.TemporaryDirectory() as temporary:
|
with tempfile.TemporaryDirectory() as temporary:
|
||||||
shared = read_shared(build_shared(root, Path(temporary) / 'shared'))
|
shared = read_shared(build_shared(root, Path(temporary) / "shared"))
|
||||||
files = {name: content for name, content in shared.items()
|
files = {
|
||||||
if name.startswith('_vendor/') or name in (
|
name: content
|
||||||
'_version.py', 'version.txt', 'LICENSE',
|
for name, content in shared.items()
|
||||||
)}
|
if name.startswith("_vendor/")
|
||||||
source = root / 'integrations/astrbot'
|
or name
|
||||||
for name in ('main.py', 'avatars.py', '_conf_schema.json',
|
in (
|
||||||
'requirements.txt', 'CHANGELOG.md'):
|
"_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()
|
files[name] = (source / name).read_bytes()
|
||||||
# AstrBot识别固定文件名logo.png;README单独使用高清原图。
|
# AstrBot识别固定文件名logo.png;README单独使用高清原图。
|
||||||
files['logo.png'] = (root / 'logo-astrbot.png').read_bytes()
|
files["logo.png"] = (root / "logo-astrbot.png").read_bytes()
|
||||||
files['logo-readme.png'] = (root / 'logo.png').read_bytes()
|
files["logo-readme.png"] = (root / "logo.png").read_bytes()
|
||||||
files['images/farm-preview.png'] = shared['images/farm-preview.png']
|
files["images/farm-preview.png"] = shared["images/farm-preview.png"]
|
||||||
files['images/help-astrbot.png'] = build_help_image(
|
files["images/help-astrbot.png"] = build_help_image(
|
||||||
root, 'astrbot', help_image,
|
root,
|
||||||
|
"astrbot",
|
||||||
|
help_image,
|
||||||
)
|
)
|
||||||
files['README.md'] = render_readme(root)
|
files["README.md"] = render_readme(root)
|
||||||
files['runtime.py'] = (
|
files["runtime.py"] = (root / "integrations/shared/runtime.py").read_bytes()
|
||||||
root / 'integrations/shared/runtime.py'
|
files["runtime_logging.py"] = (source / "runtime_logging.py").read_bytes()
|
||||||
).read_bytes()
|
files["verify_package.py"] = (
|
||||||
files['verify_package.py'] = (
|
root / "scripts/verify_astrbot.py"
|
||||||
root / 'scripts/verify_astrbot.py'
|
|
||||||
).read_bytes()
|
).read_bytes()
|
||||||
# JSON是YAML的子集;标准库即可生成AstrBot可读的metadata.yaml。
|
# JSON是YAML的子集;标准库即可生成AstrBot可读的metadata.yaml。
|
||||||
files['metadata.yaml'] = (json.dumps({
|
files["metadata.yaml"] = (
|
||||||
'name': ROOT_NAME, 'display_name': '农场',
|
json.dumps(
|
||||||
'short_desc': '开通农场,体验种植、收获、偷菜与土地开垦。',
|
{
|
||||||
'desc': '农场种植小游戏,支持种子商店、分类仓库、作物交易、'
|
"name": ROOT_NAME,
|
||||||
'除虫、偷菜、土地开垦和动态图片帮助;使用本地存档。',
|
"display_name": "真寻农场",
|
||||||
'version': version, 'author': 'Shu-Ying',
|
"short_desc": "开通农场,体验种植、收获、偷菜与土地开垦。",
|
||||||
'repo': 'https://github.com/Shu-Ying/astrbot_plugin_farm',
|
"desc": "农场种植小游戏,支持种子商店、分类仓库、作物交易、"
|
||||||
'social_link': 'https://github.com/Shu-Ying',
|
"除虫、偷菜、土地开垦和动态图片帮助;使用本地存档。",
|
||||||
'tags': ['农场', '种植', '小游戏'],
|
"version": version,
|
||||||
'support_platforms': ['aiocqhttp'],
|
"author": "Shu-Ying",
|
||||||
}, ensure_ascii=False, indent=2) + '\n').encode()
|
"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():
|
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))
|
ast.parse(content, filename=name, feature_version=(3, 10))
|
||||||
manifest = {
|
manifest = {
|
||||||
'schema': 1, 'target': 'astrbot', 'plugin': ROOT_NAME,
|
"schema": 1,
|
||||||
'version': version, 'source': source_identity(root),
|
"target": "astrbot",
|
||||||
'files': {name: hashlib.sha256(content).hexdigest()
|
"plugin": ROOT_NAME,
|
||||||
for name, content in sorted(files.items())},
|
"version": version,
|
||||||
|
"source": source_identity(root),
|
||||||
|
"files": {
|
||||||
|
name: hashlib.sha256(content).hexdigest()
|
||||||
|
for name, content in sorted(files.items())
|
||||||
|
},
|
||||||
}
|
}
|
||||||
files[MANIFEST] = (json.dumps(
|
files[MANIFEST] = (
|
||||||
manifest, ensure_ascii=False, sort_keys=True, indent=2,
|
json.dumps(
|
||||||
) + '\n').encode()
|
manifest,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
indent=2,
|
||||||
|
)
|
||||||
|
+ "\n"
|
||||||
|
).encode()
|
||||||
verify_files(files)
|
verify_files(files)
|
||||||
output.mkdir(parents=True)
|
output.mkdir(parents=True)
|
||||||
for name, content in sorted(files.items()):
|
for name, content in sorted(files.items()):
|
||||||
target = output / 'repository' / name
|
target = output / "repository" / name
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
target.write_bytes(content)
|
target.write_bytes(content)
|
||||||
archive_path = output / f'{ROOT_NAME}-{version}.zip'
|
archive_path = output / f"{ROOT_NAME}-{version}.zip"
|
||||||
with ZipFile(archive_path, 'w', compression=ZIP_DEFLATED) as archive:
|
with ZipFile(archive_path, "w", compression=ZIP_DEFLATED) as archive:
|
||||||
for name, content in sorted(files.items()):
|
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.create_system = 3
|
||||||
item.compress_type = ZIP_DEFLATED
|
item.compress_type = ZIP_DEFLATED
|
||||||
item.external_attr = 0o100644 << 16
|
item.external_attr = 0o100644 << 16
|
||||||
archive.writestr(item, content)
|
archive.writestr(item, content)
|
||||||
verify_package(archive_path)
|
verify_package(archive_path)
|
||||||
if archive_path.stat().st_size > 16_000_000:
|
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()
|
digest = hashlib.sha256(archive_path.read_bytes()).hexdigest()
|
||||||
archive_path.with_suffix('.zip.sha256').write_text(
|
archive_path.with_suffix(".zip.sha256").write_text(
|
||||||
f'{digest} {archive_path.name}\n', encoding='ascii',
|
f"{digest} {archive_path.name}\n",
|
||||||
|
encoding="ascii",
|
||||||
)
|
)
|
||||||
return archive_path
|
return archive_path
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument('--output', type=Path, required=True)
|
parser.add_argument("--output", type=Path, required=True)
|
||||||
parser.add_argument('--help-image', type=Path)
|
parser.add_argument("--help-image", type=Path)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
print(build(Path(__file__).resolve().parents[1], args.output,
|
print(
|
||||||
help_image=args.help_image))
|
build(
|
||||||
|
Path(__file__).resolve().parents[1],
|
||||||
|
args.output,
|
||||||
|
help_image=args.help_image,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
|
|||||||
from build_zhenxun import read_version, source_identity
|
from build_zhenxun import read_version, source_identity
|
||||||
from verify_nonebot import MANIFEST, ROOT_NAME, verify_files, verify_package
|
from verify_nonebot import MANIFEST, ROOT_NAME, verify_files, verify_package
|
||||||
from help_image import build_help_image
|
from help_image import build_help_image
|
||||||
|
from format_generated import format_generated
|
||||||
|
|
||||||
|
|
||||||
def build(root, output, *, help_image=None):
|
def build(root, output, *, help_image=None):
|
||||||
@@ -32,6 +33,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
|
||||||
@@ -64,6 +68,7 @@ def build(root, output, *, help_image=None):
|
|||||||
}.items():
|
}.items():
|
||||||
files[name] = (root / source).read_bytes()
|
files[name] = (root / source).read_bytes()
|
||||||
files[prefix + "_version.py"] = f'__version__ = "{version}"\n'.encode()
|
files[prefix + "_version.py"] = f'__version__ = "{version}"\n'.encode()
|
||||||
|
format_generated(root, files)
|
||||||
for name, content in files.items():
|
for name, content in files.items():
|
||||||
if name.endswith(".py"):
|
if name.endswith(".py"):
|
||||||
ast.parse(content, filename=name, feature_version=(3, 10))
|
ast.parse(content, filename=name, feature_version=(3, 10))
|
||||||
|
|||||||
@@ -43,7 +43,8 @@ foreach ($farmCandidate in $farmCandidates) {
|
|||||||
$farmPrefix = $farmCandidate.Prefix
|
$farmPrefix = $farmCandidate.Prefix
|
||||||
$farmProbe = 'import sys, importlib.util; sys.exit(' +
|
$farmProbe = 'import sys, importlib.util; sys.exit(' +
|
||||||
'sys.version_info < (3, 10) or ' +
|
'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.
|
# Windows PowerShell 5.1 rewrites quotes in native arguments.
|
||||||
# Use stdin instead of -c; keep this script ASCII for PS 5.1.
|
# Use stdin instead of -c; keep this script ASCII for PS 5.1.
|
||||||
$farmProbe | & $farmExe @farmPrefix -
|
$farmProbe | & $farmExe @farmPrefix -
|
||||||
@@ -56,8 +57,9 @@ foreach ($farmCandidate in $farmCandidates) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (-not $farmPython) {
|
if (-not $farmPython) {
|
||||||
Write-Error ('Python 3.10+ with Pillow not found. ' +
|
Write-Error ('Python 3.10+ with Pillow and Ruff not found. ' +
|
||||||
'Install Python/Pillow or set FARM_PYTHON.')
|
'Install Pillow and requirements-build.txt in the same Python, ' +
|
||||||
|
'or set FARM_PYTHON.')
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from zipfile import ZIP_DEFLATED, ZipFile, ZipInfo
|
|||||||
|
|
||||||
from verify_zhenxun import MANIFEST, ROOT_NAME, verify_files, verify_package
|
from verify_zhenxun import MANIFEST, ROOT_NAME, verify_files, verify_package
|
||||||
from help_image import build_help_image
|
from help_image import build_help_image
|
||||||
|
from format_generated import format_generated
|
||||||
|
|
||||||
|
|
||||||
def source_identity(root):
|
def source_identity(root):
|
||||||
@@ -54,9 +55,19 @@ def build(root, output, *, help_image=None):
|
|||||||
"requirements.txt", "config_migration.py",
|
"requirements.txt", "config_migration.py",
|
||||||
):
|
):
|
||||||
files[name] = (integration / name).read_bytes()
|
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"] = (
|
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
|
||||||
@@ -87,6 +98,7 @@ def build(root, output, *, help_image=None):
|
|||||||
}.items():
|
}.items():
|
||||||
files[name] = source.read_bytes()
|
files[name] = source.read_bytes()
|
||||||
files["_version.py"] = f'__version__ = "{version}"\n'.encode()
|
files["_version.py"] = f'__version__ = "{version}"\n'.encode()
|
||||||
|
format_generated(root, files)
|
||||||
for name, content in files.items():
|
for name, content in files.items():
|
||||||
if name.endswith(".py"):
|
if name.endswith(".py"):
|
||||||
ast.parse(content, filename=name, feature_version=(3, 10))
|
ast.parse(content, filename=name, feature_version=(3, 10))
|
||||||
|
|||||||
@@ -8,8 +8,7 @@ from zipfile import ZipFile
|
|||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
sys.path.insert(0, str(ROOT / 'src'))
|
sys.path.insert(0, str(ROOT / 'src'))
|
||||||
from farm_core.catalogue_release import parse_manifest
|
from farm_core.catalogue_release import ROOTS, parse_manifest
|
||||||
from farm_render.catalogue_bundle import ROOTS
|
|
||||||
|
|
||||||
|
|
||||||
def check(root):
|
def check(root):
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ def main():
|
|||||||
token = sync["steps"][-1]["env"]["FARM_RELEASE_TOKEN"]
|
token = sync["steps"][-1]["env"]["FARM_RELEASE_TOKEN"]
|
||||||
if token != "${{ secrets.FARM_RELEASE_TOKEN }}":
|
if token != "${{ secrets.FARM_RELEASE_TOKEN }}":
|
||||||
raise ValueError("NoneBot must reuse 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((
|
generated = yaml.load((
|
||||||
root / "release/nonebot/verify.yml"
|
root / "release/nonebot/verify.yml"
|
||||||
).read_text(encoding="utf-8"), Loader=yaml.BaseLoader)
|
).read_text(encoding="utf-8"), Loader=yaml.BaseLoader)
|
||||||
@@ -113,6 +115,21 @@ def main():
|
|||||||
"github.repository == 'Shu-Ying/nonebot_plugin_farm'"
|
"github.repository == 'Shu-Ying/nonebot_plugin_farm'"
|
||||||
)):
|
)):
|
||||||
raise ValueError("NoneBot PyPI boundary differs")
|
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"]
|
steps = publishing["jobs"]["publish"]["steps"]
|
||||||
if steps[-1]["with"]["password"] != "${{ secrets.PYPI_TOKEN }}":
|
if steps[-1]["with"]["password"] != "${{ secrets.PYPI_TOKEN }}":
|
||||||
raise ValueError("Keep the existing PyPI secret")
|
raise ValueError("Keep the existing PyPI secret")
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -4,6 +4,7 @@ import argparse
|
|||||||
import base64
|
import base64
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
from urllib.error import HTTPError
|
from urllib.error import HTTPError
|
||||||
from urllib.request import Request, urlopen
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
@@ -37,13 +38,19 @@ def github(method, path, payload, token, allow_missing=False):
|
|||||||
) from None
|
) from None
|
||||||
|
|
||||||
|
|
||||||
def publish(package, confirmed=False, request=None):
|
def publish(package, confirmed=False, request=None, *, release_tag=False):
|
||||||
files = read_package(package)
|
files = read_package(package)
|
||||||
manifest = verify_files(files)
|
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)
|
message = commit_message(manifest)
|
||||||
result = {"repository": REPOSITORY, "branch": BRANCH,
|
result = {"repository": REPOSITORY, "branch": BRANCH,
|
||||||
"version": manifest["version"], "message": message,
|
"version": manifest["version"], "message": message,
|
||||||
"published": False}
|
"published": False}
|
||||||
|
tag = 'v' + manifest['version']
|
||||||
|
if release_tag:
|
||||||
|
result.update(tag=tag, tag_created=False)
|
||||||
if not confirmed:
|
if not confirmed:
|
||||||
return result
|
return result
|
||||||
if manifest["source"]["dirty"]:
|
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")
|
raise ValueError("Configure FARM_RELEASE_TOKEN in a protected job")
|
||||||
call = request or (lambda method, path, data: github(
|
call = request or (lambda method, path, data: github(
|
||||||
method, path, data, token,
|
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):
|
if not call("GET", "", None):
|
||||||
raise ValueError(f"Target repository is not accessible: {REPOSITORY}")
|
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)
|
ref = call("GET", f"git/ref/heads/{BRANCH}", None)
|
||||||
parent = ref["object"]["sha"] if ref else None
|
parent = ref["object"]["sha"] if ref else None
|
||||||
if parent:
|
if parent:
|
||||||
@@ -92,15 +112,25 @@ def publish(package, confirmed=False, request=None):
|
|||||||
call("POST", "git/refs", {
|
call("POST", "git/refs", {
|
||||||
"ref": f"refs/heads/{BRANCH}", "sha": commit["sha"],
|
"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():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description=__doc__)
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
parser.add_argument("package")
|
parser.add_argument("package")
|
||||||
parser.add_argument("--publish", action="store_true")
|
parser.add_argument("--publish", action="store_true")
|
||||||
|
parser.add_argument('--release-tag', action='store_true',
|
||||||
|
help='同步后创建v版本标签,触发目标仓库PyPI发布')
|
||||||
args = parser.parse_args()
|
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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""独立验证生成的真寻包;仅标准库,不导入或启动插件。"""
|
"""独立验证生成的真寻包;仅标准库,不导入或启动插件。"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import ast
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from pathlib import Path, PurePosixPath
|
from pathlib import Path, PurePosixPath
|
||||||
@@ -45,6 +46,16 @@ def verify_files(files):
|
|||||||
raise ValueError('Resource ZIP must not be in plugin repository')
|
raise ValueError('Resource ZIP must not be in plugin repository')
|
||||||
if not required <= set(files):
|
if not required <= set(files):
|
||||||
raise ValueError("Incomplete plugin")
|
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
|
return manifest
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ FILES = {
|
|||||||
MANIFEST_LIMIT = 16 * 1024
|
MANIFEST_LIMIT = 16 * 1024
|
||||||
DIGEST = re.compile(r'[0-9a-f]{64}')
|
DIGEST = re.compile(r'[0-9a-f]{64}')
|
||||||
VERSION = re.compile(r'[A-Za-z0-9._-]{1,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):
|
def _pairs(items):
|
||||||
|
|||||||
@@ -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__,
|
||||||
)
|
)
|
||||||
|
|||||||
+99
-15
@@ -5,6 +5,7 @@ from importlib import resources
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
@@ -41,23 +42,104 @@ def checked_url(url):
|
|||||||
return 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):
|
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:
|
progress = _DownloadProgress(name)
|
||||||
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)
|
progress.update(total, length, started)
|
||||||
if total > limit or time.monotonic()-started > 60:
|
while True:
|
||||||
raise ValueError('Resource download limit or timeout')
|
chunk = response.read(min(65536, limit-total+1))
|
||||||
destination.write(chunk)
|
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):
|
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')
|
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)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import uuid
|
|||||||
from zipfile import BadZipFile
|
from zipfile import BadZipFile
|
||||||
|
|
||||||
from farm_core.catalogue_release import (
|
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,
|
parse_manifest,
|
||||||
)
|
)
|
||||||
from farm_core.domain import FarmError
|
from farm_core.domain import FarmError
|
||||||
@@ -23,12 +23,6 @@ from .release_validation import validate_archive, validate_metadata
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
_LOCK = threading.RLock()
|
_LOCK = threading.RLock()
|
||||||
_FAILURES = (OSError, ValueError, FarmError, EOFError, BadZipFile)
|
_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)
|
@dataclass(frozen=True)
|
||||||
@@ -146,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,
|
||||||
@@ -180,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)
|
||||||
@@ -192,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
|
||||||
|
|||||||
+154
-2
@@ -1,9 +1,14 @@
|
|||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
from io import BytesIO
|
from io import BytesIO, StringIO
|
||||||
|
from itertools import count
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
import tempfile
|
import tempfile
|
||||||
|
from types import SimpleNamespace
|
||||||
import unittest
|
import unittest
|
||||||
from unittest.mock import patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
from farm_render import bundle
|
from farm_render import bundle
|
||||||
|
|
||||||
@@ -12,6 +17,15 @@ ZIP = ROOT / 'src/farm_render/data/assets.zip'
|
|||||||
|
|
||||||
|
|
||||||
class BundleTests(unittest.TestCase):
|
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):
|
def setUp(self):
|
||||||
self.temp = tempfile.TemporaryDirectory()
|
self.temp = tempfile.TemporaryDirectory()
|
||||||
self.addCleanup(self.temp.cleanup)
|
self.addCleanup(self.temp.cleanup)
|
||||||
@@ -91,3 +105,141 @@ 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_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()
|
||||||
|
|||||||
@@ -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]
|
||||||
@@ -32,7 +32,7 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(manifest['version'], version)
|
self.assertEqual(manifest['version'], version)
|
||||||
self.assertEqual(json.loads(files['metadata.yaml'])['version'], version)
|
self.assertEqual(json.loads(files['metadata.yaml'])['version'], version)
|
||||||
metadata = json.loads(files['metadata.yaml'])
|
metadata = json.loads(files['metadata.yaml'])
|
||||||
self.assertEqual(metadata['display_name'], '农场')
|
self.assertEqual(metadata['display_name'], '真寻农场')
|
||||||
self.assertEqual(metadata['support_platforms'], ['aiocqhttp'])
|
self.assertEqual(metadata['support_platforms'], ['aiocqhttp'])
|
||||||
self.assertTrue(metadata['short_desc'])
|
self.assertTrue(metadata['short_desc'])
|
||||||
self.assertEqual(files['logo.png'],
|
self.assertEqual(files['logo.png'],
|
||||||
@@ -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 = {}
|
||||||
|
|
||||||
@@ -129,6 +154,7 @@ class AstrBotPackageTests(unittest.IsolatedAsyncioTestCase):
|
|||||||
self.assertEqual(plugin.runtime.data_dir,
|
self.assertEqual(plugin.runtime.data_dir,
|
||||||
(self.root / 'existing-custom-data').resolve())
|
(self.root / 'existing-custom-data').resolve())
|
||||||
self.assertIsNone(plugin.runtime.font_path)
|
self.assertIsNone(plugin.runtime.font_path)
|
||||||
|
self.assertEqual(plugin.runtime.resource_source, 'Github')
|
||||||
# 使用真实开发素材预置缓存,模拟宿主测试不访问网络。
|
# 使用真实开发素材预置缓存,模拟宿主测试不访问网络。
|
||||||
manifest = json.loads((ROOT /
|
manifest = json.loads((ROOT /
|
||||||
'src/farm_render/data/asset-manifest.json').read_bytes())
|
'src/farm_render/data/asset-manifest.json').read_bytes())
|
||||||
@@ -152,6 +178,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:
|
||||||
|
|||||||
@@ -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"})
|
||||||
@@ -25,9 +25,13 @@ class NoneBotPackageTests(unittest.TestCase):
|
|||||||
def test_reproducible_standard_package_and_shared_runtime(self):
|
def test_reproducible_standard_package_and_shared_runtime(self):
|
||||||
other = build(ROOT, self.root / "second")
|
other = build(ROOT, self.root / "second")
|
||||||
self.assertEqual(self.bundle.read_bytes(), other.read_bytes())
|
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"
|
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",
|
for suffix in ("farm_core/data/plants.json",
|
||||||
"farm_render/data/asset-manifest.json",
|
"farm_render/data/asset-manifest.json",
|
||||||
"farm_render/data/font-manifest.json",
|
"farm_render/data/font-manifest.json",
|
||||||
@@ -165,3 +169,59 @@ class NoneBotPackageTests(unittest.TestCase):
|
|||||||
with self.assertRaises((ValueError, RuntimeError)):
|
with self.assertRaises((ValueError, RuntimeError)):
|
||||||
publish(bundle, True, request)
|
publish(bundle, True, request)
|
||||||
self.assertFalse(any(method == "PATCH" for method, _, _ in calls))
|
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'])
|
||||||
|
|||||||
@@ -157,6 +157,11 @@ class ZhenxunPackageTests(unittest.TestCase):
|
|||||||
self.assertEqual(archive.read(
|
self.assertEqual(archive.read(
|
||||||
"zhenxun_plugin_farm_preview/_version.py",
|
"zhenxun_plugin_farm_preview/_version.py",
|
||||||
), b'__version__ = "1.2.3-preview.4"\n')
|
), 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):
|
def clean_package(self):
|
||||||
from verify_zhenxun import read_package, MANIFEST, ROOT_NAME
|
from verify_zhenxun import read_package, MANIFEST, ROOT_NAME
|
||||||
|
|||||||
@@ -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.6
|
||||||
Reference in New Issue
Block a user