fixed # 修改进度条表现

add # 新增Ruff代码格式工作流
This commit is contained in:
2026-10-27 23:02:43 +08:00
parent c6b504924e
commit b09d51c7c9
20 changed files with 486 additions and 136 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
+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
+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`许可证进行开源
+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,插件会明确拒绝启动,避免
+1
View File
@@ -1,2 +1,3 @@
Pillow>=10.4
aiohttp>=3.9,<4
rich>=13.9,<16
+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"
+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
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
}
+2
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):
@@ -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))
+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)
+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
+96 -12
View File
@@ -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()
+1 -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'],
+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"})
+6 -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",