mirror of
https://github.com/Shu-Ying/main_plugin_farm.git
synced 2026-10-29 13:40:00 +08:00
fixed # 修改进度条表现
add # 新增Ruff代码格式工作流
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`许可证进行开源
|
||||
|
||||
|
||||
@@ -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,插件会明确拒绝启动,避免
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
Pillow>=10.4
|
||||
aiohttp>=3.9,<4
|
||||
rich>=13.9,<16
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
# 使用宿主已有的NoneBot/真寻/Alconna/Uninfo,不重复安装宿主。
|
||||
# 与真寻当前pillow<11的约束取交集,不升级到12。
|
||||
Pillow>=10.4,<11
|
||||
rich>=13.9,<16
|
||||
|
||||
+8
-1
@@ -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"
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
# Keep in sync with tool.ruff.required-version in pyproject.toml.
|
||||
ruff==0.8.6
|
||||
+144
-95
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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):
|
||||
@@ -97,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))
|
||||
|
||||
@@ -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)
|
||||
+67
-12
@@ -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
|
||||
|
||||
|
||||
+96
-12
@@ -1,11 +1,13 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from io import BytesIO
|
||||
from io import BytesIO, StringIO
|
||||
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 +105,43 @@ 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
|
||||
|
||||
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
|
||||
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(
|
||||
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 +150,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 +168,63 @@ 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):
|
||||
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.object(bundle.sys, 'stderr', terminal), \
|
||||
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)
|
||||
self.assertTrue(terminal.getvalue().endswith('\n'))
|
||||
self.assertNotIn('100.0%', terminal.getvalue())
|
||||
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()
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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):
|
||||
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",
|
||||
|
||||
Reference in New Issue
Block a user