diff --git a/.github/workflows/astrbot-preview.yml b/.github/workflows/astrbot-preview.yml index 8103cec..d9f39c7 100644 --- a/.github/workflows/astrbot-preview.yml +++ b/.github/workflows/astrbot-preview.yml @@ -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 diff --git a/.github/workflows/nonebot-preview.yml b/.github/workflows/nonebot-preview.yml index 9efd563..5931719 100644 --- a/.github/workflows/nonebot-preview.yml +++ b/.github/workflows/nonebot-preview.yml @@ -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 diff --git a/.github/workflows/zhenxun-preview.yml b/.github/workflows/zhenxun-preview.yml index 7eba477..849840d 100644 --- a/.github/workflows/zhenxun-preview.yml +++ b/.github/workflows/zhenxun-preview.yml @@ -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 diff --git a/README.md b/README.md index 0c1eb31..46ba44b 100644 --- a/README.md +++ b/README.md @@ -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`许可证进行开源 diff --git a/docs/catalogue-updates.md b/docs/catalogue-updates.md index b84f51e..7729b17 100644 --- a/docs/catalogue-updates.md +++ b/docs/catalogue-updates.md @@ -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,插件会明确拒绝启动,避免 diff --git a/integrations/astrbot/requirements.txt b/integrations/astrbot/requirements.txt index 0bc85bb..73d7b0d 100644 --- a/integrations/astrbot/requirements.txt +++ b/integrations/astrbot/requirements.txt @@ -1,2 +1,3 @@ Pillow>=10.4 aiohttp>=3.9,<4 +rich>=13.9,<16 diff --git a/integrations/zhenxun/requirements.txt b/integrations/zhenxun/requirements.txt index 4d506e2..3fb1b7e 100644 --- a/integrations/zhenxun/requirements.txt +++ b/integrations/zhenxun/requirements.txt @@ -1,3 +1,4 @@ # 使用宿主已有的NoneBot/真寻/Alconna/Uninfo,不重复安装宿主。 # 与真寻当前pillow<11的约束取交集,不升级到12。 Pillow>=10.4,<11 +rich>=13.9,<16 diff --git a/pyproject.toml b/pyproject.toml index e325b20..e06c203 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/release/nonebot/pyproject.toml b/release/nonebot/pyproject.toml index 82182b8..71fe9ba 100644 --- a/release/nonebot/pyproject.toml +++ b/release/nonebot/pyproject.toml @@ -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] diff --git a/requirements-build.txt b/requirements-build.txt new file mode 100644 index 0000000..4a0e266 --- /dev/null +++ b/requirements-build.txt @@ -0,0 +1,2 @@ +# Keep in sync with tool.ruff.required-version in pyproject.toml. +ruff==0.8.6 diff --git a/scripts/build_astrbot.py b/scripts/build_astrbot.py index 8ea73c0..f247c82 100644 --- a/scripts/build_astrbot.py +++ b/scripts/build_astrbot.py @@ -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 = '' + template = (root / "readme/astrbot.md").read_text(encoding="utf-8") + marker = "" 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() diff --git a/scripts/build_nonebot.py b/scripts/build_nonebot.py index 990f93b..158f86f 100644 --- a/scripts/build_nonebot.py +++ b/scripts/build_nonebot.py @@ -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)) diff --git a/scripts/build_plugin.ps1 b/scripts/build_plugin.ps1 index 0b2417b..ef27036 100644 --- a/scripts/build_plugin.ps1 +++ b/scripts/build_plugin.ps1 @@ -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 } diff --git a/scripts/build_zhenxun.py b/scripts/build_zhenxun.py index f47f0f5..c34006e 100644 --- a/scripts/build_zhenxun.py +++ b/scripts/build_zhenxun.py @@ -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)) diff --git a/scripts/format_generated.py b/scripts/format_generated.py new file mode 100644 index 0000000..518b476 --- /dev/null +++ b/scripts/format_generated.py @@ -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) diff --git a/src/farm_render/bundle.py b/src/farm_render/bundle.py index 42455e5..4646006 100644 --- a/src/farm_render/bundle.py +++ b/src/farm_render/bundle.py @@ -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 diff --git a/tests/test_asset_bundle.py b/tests/test_asset_bundle.py index ce00ca1..ffc0847 100644 --- a/tests/test_asset_bundle.py +++ b/tests/test_asset_bundle.py @@ -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() diff --git a/tests/test_astrbot_package.py b/tests/test_astrbot_package.py index 868d8eb..4481cdb 100644 --- a/tests/test_astrbot_package.py +++ b/tests/test_astrbot_package.py @@ -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'], diff --git a/tests/test_format_generated.py b/tests/test_format_generated.py new file mode 100644 index 0000000..c62655c --- /dev/null +++ b/tests/test_format_generated.py @@ -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"}) diff --git a/tests/test_nonebot_package.py b/tests/test_nonebot_package.py index b6456e9..d8f6d35 100644 --- a/tests/test_nonebot_package.py +++ b/tests/test_nonebot_package.py @@ -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",