Compare commits

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