Compare commits

...

2 Commits

Author SHA1 Message Date
Soulter
51bb487346 fix: address release workflow review feedback 2026-06-19 15:29:48 +08:00
Soulter
a2567a202e chore: add release preparation workflow 2026-06-19 15:19:32 +08:00
6 changed files with 868 additions and 9 deletions

View File

@@ -51,6 +51,12 @@ ruff check .
6. For path handling, use `pathlib.Path` instead of string paths, and use `astrbot.core.utils.path_utils` to get the AstrBot data and temp directory.
7. When backend API routes, request/response schemas, or OpenAPI definitions change, regenerate the frontend API client by running `cd dashboard && pnpm generate:api`.
### KISS and First Principles
Follow the KISS principle and reason from first principles during development. Start by identifying the real problem, required behavior, and smallest useful change before adding code. Do not pile on features, configuration switches, abstractions, dependencies, or compatibility layers unless they directly solve the current problem and have clear evidence of need.
Prefer the simplest implementation that is correct, maintainable, and consistent with the existing codebase. If a broader design seems attractive, reduce it to the essential behavior needed now and leave optional expansion for a later, explicit requirement.
### No Unnecessary Helpers
Prioritize inline implementation over abstraction. Avoid over-engineering and do not create helper functions unless absolutely necessary.
@@ -94,7 +100,34 @@ def calculate_metrics(user_id: int, force_refresh: bool = False) -> dict:
## Release versions
1. Replace current version name to specific version name.
2. Write changelog in `changelogs/`, you can refer to the full commit messages between the latest tag to the latest commit.
3. Make and push a commit into master branch with message format like: `chore: bump version to 4.25.0`
4. Create a tag and push the tag. For example: `git tag v4.25.0 && git push origin v4.25.0`
Use a short-lived `release/*` branch for each release. The release branch is the stabilization area for version bumps, changelog updates, release-blocking fixes, and final validation only. Do not add unrelated features or broad refactors to a release branch.
Prepare a release from a clean worktree with:
```bash
uv run python scripts/prepare_release.py 4.25.0
```
The script updates `pyproject.toml`, creates `changelogs/v4.25.0.md`, runs the required Python checks, and prints the remaining steps. Use these flags when needed:
```bash
uv run python scripts/prepare_release.py 4.25.0 --generate-api-client
uv run python scripts/prepare_release.py 4.25.0 --dashboard-build
uv run python scripts/prepare_release.py 4.25.0 --commit --push
```
Open a PR from `release/4.25.0` to `master`. The PR title must use the conventional commit format, for example `chore: bump version to 4.25.0`. After the release PR is merged, create and push the tag from the updated `master` branch so the tag points to the exact code that was merged:
```bash
git checkout master
git pull --ff-only origin master
git tag v4.25.0
git push origin v4.25.0
```
For one-off release candidate branches, delete the release branch after the tag is pushed and verified. For maintained release lines, use a branch such as `release/4.25` and keep it until that line reaches EOL.
```bash
git branch -d release/4.25.0
git push origin --delete release/4.25.0
```

View File

@@ -1,11 +1,39 @@
"""如需修改配置,请在 `data/cmd_config.json` 中修改或者在管理面板中可视化修改。"""
import os
import re
from importlib.metadata import PackageNotFoundError
from importlib.metadata import version as package_version
from pathlib import Path
from astrbot.core.computer.booters.cua_defaults import CUA_DEFAULT_CONFIG
from astrbot.core.utils.astrbot_path import get_astrbot_data_path
from astrbot.core.utils.toml_parser import read_pyproject_project_version
try:
import tomllib
except ModuleNotFoundError:
# <= Python 3.10 compatibility
tomllib = None
try:
pyproject_path = Path(__file__).resolve().parents[3] / "pyproject.toml"
if tomllib is None:
VERSION = read_pyproject_project_version(pyproject_path)
else:
with pyproject_path.open("rb") as f:
VERSION = tomllib.load(f)["project"]["version"]
except (FileNotFoundError, IndexError, KeyError, TypeError, ValueError):
try:
VERSION = package_version("astrbot") # PEP 440 version style, e.g. 1.2.3a4
match = re.match(r"^(\d+(?:\.\d+)*)(a|b|rc)(\d+)$", VERSION)
if match:
release, prerelease, number = match.groups()
prerelease = {"a": "alpha", "b": "beta", "rc": "rc"}[prerelease]
VERSION = f"{release}-{prerelease}.{number}"
except PackageNotFoundError:
VERSION = "0.0.0"
VERSION = "4.26.0-beta.8"
DB_PATH = os.path.join(get_astrbot_data_path(), "data_v4.db")
PERSONAL_WECHAT_CONFIG_METADATA = {
"weixin_oc_base_url": {

View File

@@ -0,0 +1,184 @@
"""Small TOML readers for bootstrapping paths without parser dependencies."""
from pathlib import Path
def _read_quoted_value(value: str, field_name: str) -> tuple[str, str]:
"""Read one quoted TOML string value and return its tail.
Args:
value: Raw value text that starts with a quoted string.
field_name: Field name used in error messages.
Returns:
A tuple containing the unquoted string and the remaining text.
Raises:
ValueError: The value is not a supported quoted string.
"""
value = value.strip()
if len(value) < 2 or value[0] not in ("'", '"'):
raise ValueError(f"Unsupported {field_name} value")
quote = value[0]
end_index = value.find(quote, 1)
if end_index == -1:
raise ValueError(f"Unterminated {field_name} string")
result = value[1:end_index]
if not result:
raise ValueError(f"Empty {field_name} value")
return result, value[end_index + 1 :].strip()
def _read_dependency_array(raw_value: str) -> list[str]:
"""Read a simple inline TOML string array.
Args:
raw_value: Raw dependency array text, including the surrounding brackets.
Returns:
Parsed dependency strings.
Raises:
ValueError: The array is missing brackets or contains unsupported entries.
"""
value = raw_value.strip()
if not value.startswith("["):
raise ValueError("Unsupported project.dependencies value")
dependencies = []
value = value[1:].strip()
while value:
if value.startswith("]"):
tail = value[1:].strip()
if tail and not tail.startswith("#"):
raise ValueError("Unsupported content after project.dependencies")
return dependencies
dependency, tail = _read_quoted_value(value, "project.dependencies entry")
dependencies.append(dependency)
if tail.startswith(","):
value = tail[1:].strip()
continue
if tail.startswith("]"):
value = tail
continue
if tail:
raise ValueError("Unsupported content after project.dependencies entry")
raise ValueError("Unterminated project.dependencies array")
raise ValueError("Unterminated project.dependencies array")
def read_pyproject_project_version(pyproject_path: Path) -> str:
"""Read the project version from a pyproject.toml file.
Args:
pyproject_path: Path to the pyproject.toml file.
Returns:
The value of the project.version field.
Raises:
FileNotFoundError: The pyproject.toml file does not exist.
ValueError: The project.version field is missing or unsupported.
"""
in_project_section = False
for raw_line in pyproject_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if line.startswith("[") and line.endswith("]"):
in_project_section = line == "[project]"
continue
if not in_project_section:
continue
key, separator, raw_value = line.partition("=")
if key.strip() != "version":
continue
if not separator:
raise ValueError("Missing value separator for project.version")
version, tail = _read_quoted_value(raw_value, "project.version")
if tail and not tail.startswith("#"):
raise ValueError("Unsupported content after project.version")
return version
raise ValueError("Missing project.version")
def read_pyproject_project_dependencies(pyproject_path: Path) -> list[str]:
"""Read project dependencies from a pyproject.toml file.
Args:
pyproject_path: Path to the pyproject.toml file.
Returns:
The values in the project.dependencies array.
Raises:
FileNotFoundError: The pyproject.toml file does not exist.
ValueError: The project.dependencies field is missing or unsupported.
"""
dependencies = []
in_project_section = False
in_dependencies_array = False
for raw_line in pyproject_path.read_text(encoding="utf-8").splitlines():
line = raw_line.strip()
if not line or line.startswith("#"):
continue
if in_dependencies_array:
if line.startswith("]"):
tail = line[1:].strip()
if tail and not tail.startswith("#"):
raise ValueError("Unsupported content after project.dependencies")
return dependencies
dependency, tail = _read_quoted_value(
line,
"project.dependencies entry",
)
if tail.startswith(","):
tail = tail[1:].strip()
if tail.startswith("]"):
tail = tail[1:].strip()
dependencies.append(dependency)
if tail and not tail.startswith("#"):
raise ValueError("Unsupported content after project.dependencies")
return dependencies
if tail and not tail.startswith("#"):
raise ValueError("Unsupported content after project.dependencies entry")
dependencies.append(dependency)
continue
if line.startswith("[") and line.endswith("]"):
in_project_section = line == "[project]"
continue
if not in_project_section:
continue
key, separator, raw_value = line.partition("=")
if key.strip() != "dependencies":
continue
if not separator:
raise ValueError("Unsupported project.dependencies value")
raw_value = raw_value.strip()
if raw_value == "[" or raw_value.startswith("[ #"):
in_dependencies_array = True
continue
if raw_value.startswith("["):
return _read_dependency_array(raw_value)
raise ValueError("Unsupported project.dependencies value")
if in_dependencies_array:
raise ValueError("Unterminated project.dependencies array")
raise ValueError("Missing project.dependencies")

431
scripts/prepare_release.py Normal file
View File

@@ -0,0 +1,431 @@
#!/usr/bin/env python3
"""Prepare an AstrBot release branch and release metadata."""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
VERSION_PATTERN = re.compile(r"^\d+\.\d+\.\d+(?:[-+._a-zA-Z0-9]+)?$")
class ReleaseError(RuntimeError):
"""Error raised when a release preparation step cannot continue."""
def run_command(
args: list[str],
*,
cwd: Path = REPO_ROOT,
capture_output: bool = False,
) -> str:
"""Run a command and return captured stdout when requested.
Args:
args: Command and arguments to run.
cwd: Working directory for the command.
capture_output: Whether to capture and return stdout instead of streaming it.
Returns:
Captured stdout without surrounding whitespace when capture_output is true;
otherwise an empty string.
Raises:
ReleaseError: The command is missing or exits with a non-zero status.
"""
printable = " ".join(args)
print(f"$ {printable}")
try:
if capture_output:
result = subprocess.run(
args,
cwd=cwd,
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()
subprocess.run(args, cwd=cwd, check=True)
return ""
except FileNotFoundError as exc:
raise ReleaseError(f"Command not found: {args[0]}") from exc
except subprocess.CalledProcessError as exc:
if capture_output and exc.stderr:
print(exc.stderr.strip(), file=sys.stderr)
raise ReleaseError(f"Command failed ({exc.returncode}): {printable}") from exc
def git(args: list[str], *, capture_output: bool = False) -> str:
"""Run a git command in the repository root.
Args:
args: Arguments to pass after `git`.
capture_output: Whether to capture and return stdout.
Returns:
Captured stdout when capture_output is true; otherwise an empty string.
Raises:
ReleaseError: Git exits with a non-zero status.
"""
return run_command(["git", *args], capture_output=capture_output)
def ensure_clean_worktree() -> None:
"""Ensure the release starts from a clean worktree.
Raises:
ReleaseError: The repository contains tracked or untracked changes.
"""
status = git(["status", "--porcelain"], capture_output=True)
if status:
raise ReleaseError(
"Working tree must be clean before preparing a release.\n"
"Commit, stash, or remove these changes first:\n"
f"{status}"
)
def validate_version(version: str) -> str:
"""Validate a release version string.
Args:
version: Version string without the leading tag prefix.
Returns:
The validated version string.
Raises:
ReleaseError: The version is empty, starts with `v`, or has an unsupported
shape.
"""
if version.startswith("v"):
raise ReleaseError(
"Pass the version without the tag prefix, for example 4.25.0"
)
if not VERSION_PATTERN.fullmatch(version):
raise ReleaseError(
"Unsupported version format. Expected a value like 4.25.0 or 4.26.0-beta.8"
)
return version
def latest_tag() -> str:
"""Return the most recent reachable tag, if one exists.
Returns:
The latest tag name, or an empty string when the repository has no tags.
"""
try:
return git(["describe", "--tags", "--abbrev=0"], capture_output=True)
except ReleaseError:
return ""
def release_commits(tag: str) -> list[str]:
"""Read commit subjects for the release range.
Args:
tag: Latest tag to use as the lower bound. When empty, all reachable
commits are considered.
Returns:
Commit subjects formatted for changelog draft entries.
Raises:
ReleaseError: Git log fails.
"""
log_range = f"{tag}..HEAD" if tag else "HEAD"
output = git(
["log", "--reverse", "--pretty=format:%s (%h)", log_range],
capture_output=True,
)
return [line for line in output.splitlines() if line.strip()]
def update_pyproject_version(version: str) -> Path:
"""Update `[project].version` in pyproject.toml.
Args:
version: Release version to write.
Returns:
Path to the modified pyproject.toml file.
Raises:
ReleaseError: The project version field cannot be found or parsed.
"""
pyproject_path = REPO_ROOT / "pyproject.toml"
lines = pyproject_path.read_text(encoding="utf-8").splitlines(keepends=True)
in_project_section = False
for index, line in enumerate(lines):
stripped = line.strip()
if stripped.startswith("[") and stripped.endswith("]"):
in_project_section = stripped == "[project]"
continue
if not in_project_section:
continue
key, separator, _raw_value = stripped.partition("=")
if key.strip() != "version":
continue
if not separator:
raise ReleaseError("Unsupported pyproject.toml project.version format")
match = re.match(
r"^(\s*version\s*=\s*)([\"'])(.*?)(\2)(\s*(?:#.*)?)(\n?)$",
line,
)
if not match:
raise ReleaseError("Unsupported pyproject.toml project.version format")
prefix, quote, _current, _closing_quote, suffix, newline = match.groups()
lines[index] = f"{prefix}{quote}{version}{quote}{suffix}{newline}"
pyproject_path.write_text("".join(lines), encoding="utf-8")
return pyproject_path
raise ReleaseError("Missing [project].version in pyproject.toml")
def write_changelog(version: str, commits: list[str]) -> Path:
"""Write a changelog draft for the release.
Args:
version: Release version without the leading `v`.
commits: Commit subject lines to include as the first changelog draft.
Returns:
Path to the created changelog file.
Raises:
ReleaseError: The changelog file already exists.
"""
changelog_path = REPO_ROOT / "changelogs" / f"v{version}.md"
if changelog_path.exists():
raise ReleaseError(f"Changelog already exists: {changelog_path}")
changelog_path.parent.mkdir(parents=True, exist_ok=True)
entries = [f"- {commit}" for commit in commits] or ["- "]
changelog_path.write_text(
"\n".join(
[
"## What's Changed",
"",
"<!-- Review, group, and polish these entries before publishing. -->",
"",
*entries,
"",
]
),
encoding="utf-8",
)
return changelog_path
def create_release_branch(version: str, base_branch: str, remote: str) -> str:
"""Create a release branch from the updated base branch.
Args:
version: Release version without the leading `v`.
base_branch: Base branch to release from.
remote: Remote name used for fetching and fast-forward pulls.
Returns:
Created release branch name.
Raises:
ReleaseError: The branch already exists or Git cannot create it.
"""
branch = f"release/{version}"
git(["checkout", base_branch])
git(["pull", "--ff-only", remote, base_branch])
git(["fetch", "--tags", remote])
local_branch = git(["branch", "--list", branch], capture_output=True)
if local_branch:
raise ReleaseError(f"Local branch already exists: {branch}")
remote_branch = git(["ls-remote", "--heads", remote, branch], capture_output=True)
if remote_branch:
raise ReleaseError(f"Remote branch already exists: {remote}/{branch}")
git(["switch", "-c", branch])
return branch
def run_validation(args: argparse.Namespace) -> None:
"""Run release validation commands selected by CLI flags.
Args:
args: Parsed CLI arguments.
Raises:
ReleaseError: A validation command fails.
"""
if args.generate_api_client:
run_command(["pnpm", "generate:api"], cwd=REPO_ROOT / "dashboard")
if not args.skip_checks:
run_command(["uv", "run", "ruff", "format", "--check", "."])
run_command(["uv", "run", "ruff", "check", "."])
if args.dashboard_build:
run_command(["pnpm", "install"], cwd=REPO_ROOT / "dashboard")
run_command(["pnpm", "build"], cwd=REPO_ROOT / "dashboard")
def commit_and_maybe_push(
version: str,
branch: str,
changelog_path: Path,
args: argparse.Namespace,
) -> None:
"""Commit release preparation changes and optionally push the branch.
Args:
version: Release version without the leading `v`.
branch: Release branch name.
changelog_path: Changelog file created for this release.
args: Parsed CLI arguments.
Raises:
ReleaseError: Git add, commit, or push fails.
"""
git(["add", "pyproject.toml", str(changelog_path.relative_to(REPO_ROOT))])
if args.generate_api_client:
git(["add", "dashboard/src/api/generated"])
git(["commit", "-m", f"chore: bump version to {version}"])
if args.push:
git(["push", "-u", args.remote, branch])
def print_next_steps(
version: str,
branch: str,
changelog_path: Path,
args: argparse.Namespace,
) -> None:
"""Print the manual steps that remain after preparation.
Args:
version: Release version without the leading `v`.
branch: Release branch name.
changelog_path: Changelog file created for this release.
args: Parsed CLI arguments.
"""
changelog_rel = changelog_path.relative_to(REPO_ROOT)
print("\nRelease preparation complete.")
print(f"Branch: {branch}")
print(f"Changelog: {changelog_rel}")
if args.commit:
if not args.push:
print(f"Next: git push -u {args.remote} {branch}")
else:
print("Next:")
print(f"1. Review and polish {changelog_rel}")
print(f"2. git add pyproject.toml {changelog_rel}")
print(f'3. git commit -m "chore: bump version to {version}"')
print(f"4. git push -u {args.remote} {branch}")
print(f"Open a PR from {branch} to {args.base_branch}.")
print(
"After the PR is merged, tag from the updated base branch with "
f"`git tag v{version}` and `git push {args.remote} v{version}`."
)
def parse_args(argv: list[str]) -> argparse.Namespace:
"""Parse command-line arguments.
Args:
argv: Raw command-line arguments excluding the executable name.
Returns:
Parsed CLI arguments.
Raises:
ReleaseError: Push is requested without commit.
"""
parser = argparse.ArgumentParser(
description="Prepare an AstrBot release branch, version bump, and changelog.",
)
parser.add_argument("version", help="Release version without the leading v")
parser.add_argument("--base-branch", default="master", help="Release base branch")
parser.add_argument("--remote", default="origin", help="Git remote name")
parser.add_argument(
"--generate-api-client",
action="store_true",
help="Run dashboard API client generation before validation",
)
parser.add_argument(
"--dashboard-build",
action="store_true",
help="Run dashboard install and build validation",
)
parser.add_argument(
"--skip-checks",
action="store_true",
help="Skip ruff format and ruff check",
)
parser.add_argument(
"--commit",
action="store_true",
help="Commit the generated release preparation changes",
)
parser.add_argument(
"--push",
action="store_true",
help="Push the release branch after committing; requires --commit",
)
args = parser.parse_args(argv)
if args.push and not args.commit:
raise ReleaseError("--push requires --commit")
return args
def main(argv: list[str] | None = None) -> int:
"""Run the release preparation workflow.
Args:
argv: Optional command-line arguments for tests or programmatic calls.
Returns:
Process exit code.
"""
try:
args = parse_args(sys.argv[1:] if argv is None else argv)
version = validate_version(args.version)
ensure_clean_worktree()
branch = create_release_branch(version, args.base_branch, args.remote)
tag = latest_tag()
if tag:
print(f"Latest tag: {tag}")
else:
print("No existing tags found; changelog will use all reachable commits.")
commits = release_commits(tag)
update_pyproject_version(version)
changelog_path = write_changelog(version, commits)
run_validation(args)
if args.commit:
commit_and_maybe_push(version, branch, changelog_path, args)
print_next_steps(version, branch, changelog_path, args)
return 0
except ReleaseError as exc:
print(f"prepare-release: {exc}", file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -2,7 +2,8 @@ import re
from pathlib import Path
import pytest
import tomllib
from astrbot.core.utils.toml_parser import read_pyproject_project_dependencies
PROJECT_ROOT = Path(__file__).resolve().parents[1]
REQUIREMENTS_PATH = PROJECT_ROOT / "requirements.txt"
@@ -28,9 +29,7 @@ def _read_requirements() -> list[str]:
def _read_pyproject_dependencies() -> list[str]:
with PYPROJECT_PATH.open("rb") as file:
pyproject = tomllib.load(file)
return pyproject["project"]["dependencies"]
return read_pyproject_project_dependencies(PYPROJECT_PATH)
def test_requirements_include_httpx_socks_dependency() -> None:

184
tests/test_toml_parser.py Normal file
View File

@@ -0,0 +1,184 @@
from pathlib import Path
import pytest
from astrbot.core.utils.toml_parser import (
read_pyproject_project_dependencies,
read_pyproject_project_version,
)
def test_read_pyproject_project_version_reads_project_section(tmp_path: Path) -> None:
pyproject_path = tmp_path / "pyproject.toml"
pyproject_path.write_text(
"\n".join(
[
'version = "ignored"',
"[project]",
'name = "AstrBot"',
'version = "1.2.3-beta.4" # release version',
"[tool.example]",
'version = "ignored-again"',
]
),
encoding="utf-8",
)
assert read_pyproject_project_version(pyproject_path) == "1.2.3-beta.4"
@pytest.mark.parametrize(
("version_line", "expected"),
[
('version = "1.2.3"', "1.2.3"),
("version='1.2.3-beta.4'", "1.2.3-beta.4"),
(' version = "1.2.3-rc.1" ', "1.2.3-rc.1"),
],
)
def test_read_pyproject_project_version_accepts_simple_variants(
tmp_path: Path,
version_line: str,
expected: str,
) -> None:
pyproject_path = tmp_path / "pyproject.toml"
pyproject_path.write_text(
"\n".join(
[
"[project]",
'name = "AstrBot"',
version_line,
]
),
encoding="utf-8",
)
assert read_pyproject_project_version(pyproject_path) == expected
@pytest.mark.parametrize(
("version_line", "message"),
[
("version", "Missing value separator for project.version"),
('version = "1.2.3', "Unterminated project.version string"),
('version = "1.2.3" extra', "Unsupported content after project.version"),
('version = ""', "Empty project.version value"),
],
)
def test_read_pyproject_project_version_rejects_invalid_values(
tmp_path: Path,
version_line: str,
message: str,
) -> None:
pyproject_path = tmp_path / "pyproject.toml"
pyproject_path.write_text(
"\n".join(
[
"[project]",
'name = "AstrBot"',
version_line,
]
),
encoding="utf-8",
)
with pytest.raises(ValueError, match=message):
read_pyproject_project_version(pyproject_path)
def test_read_pyproject_project_dependencies_reads_multiline_array(
tmp_path: Path,
) -> None:
pyproject_path = tmp_path / "pyproject.toml"
pyproject_path.write_text(
"\n".join(
[
"[project]",
"dependencies = [",
' "aiohttp>=3.11.18",',
" \"audioop-lts ; python_full_version >= '3.13'\", # marker",
"] # end dependencies",
]
),
encoding="utf-8",
)
assert read_pyproject_project_dependencies(pyproject_path) == [
"aiohttp>=3.11.18",
"audioop-lts ; python_full_version >= '3.13'",
]
@pytest.mark.parametrize(
("dependency_line", "expected"),
[
("dependencies = []", []),
('dependencies = ["aiohttp>=3.11.18"]', ["aiohttp>=3.11.18"]),
(
'dependencies = ["psutil>=5.8.0,<7.2.0", "httpx[socks]>=0.28.1"]',
["psutil>=5.8.0,<7.2.0", "httpx[socks]>=0.28.1"],
),
],
)
def test_read_pyproject_project_dependencies_accepts_inline_arrays(
tmp_path: Path,
dependency_line: str,
expected: list[str],
) -> None:
pyproject_path = tmp_path / "pyproject.toml"
pyproject_path.write_text(
"\n".join(
[
"[project]",
dependency_line,
]
),
encoding="utf-8",
)
assert read_pyproject_project_dependencies(pyproject_path) == expected
@pytest.mark.parametrize(
("project_lines", "message"),
[
(["[project]", 'name = "AstrBot"'], "Missing project.dependencies"),
(
["[project]", "dependencies = ["],
"Unterminated project.dependencies array",
),
(
["[project]", 'dependencies = "aiohttp>=3.11.18"'],
"Unsupported project.dependencies value",
),
(
["[project]", "dependencies = [", " aiohttp>=3.11.18,", "]"],
"Unsupported project.dependencies entry value",
),
(
["[project]", "dependencies = [", ' "aiohttp>=3.11.18" extra', "]"],
"Unsupported content after project.dependencies entry",
),
(
["[project]", "dependencies = [", ' ""', "]"],
"Empty project.dependencies entry value",
),
],
)
def test_read_pyproject_project_dependencies_rejects_invalid_values(
tmp_path: Path,
project_lines: list[str],
message: str,
) -> None:
pyproject_path = tmp_path / "pyproject.toml"
pyproject_path.write_text("\n".join(project_lines), encoding="utf-8")
with pytest.raises(ValueError, match=message):
read_pyproject_project_dependencies(pyproject_path)
def test_read_pyproject_project_version_raises_when_missing(tmp_path: Path) -> None:
pyproject_path = tmp_path / "pyproject.toml"
pyproject_path.write_text('[project]\nname = "AstrBot"\n', encoding="utf-8")
with pytest.raises(ValueError, match="Missing project.version"):
read_pyproject_project_version(pyproject_path)