diff --git a/README.md b/README.md index 67bf9e6..3b52c48 100644 --- a/README.md +++ b/README.md @@ -89,3 +89,13 @@ seed 会写入默认角色、默认管理员、系统菜单、系统配置、系 uv sync --extra dev uv run pytest -q ``` + +## 发布 + +`iTi-System` 独立发布。 +它不跟随 `iTi-Flask` 自动发版。 + +```bash +./scripts/iti-system.sh release +./scripts/iti-system.sh release v0.2.1 +``` diff --git a/iti_system/__about__.py b/iti_system/__about__.py new file mode 100644 index 0000000..cfc734b --- /dev/null +++ b/iti_system/__about__.py @@ -0,0 +1,4 @@ +# SPDX-FileCopyrightText: 2025-present NoahLan <6995syu@163.com> +# +# SPDX-License-Identifier: MIT +__version__ = "0.2.0" diff --git a/iti_system/__init__.py b/iti_system/__init__.py index 29963c6..e8e4ea5 100644 --- a/iti_system/__init__.py +++ b/iti_system/__init__.py @@ -1,6 +1,19 @@ -from .module import SystemModule, create_system_module +from .__about__ import __version__ __all__ = [ "SystemModule", + "__version__", "create_system_module", ] + + +def __getattr__(name: str): + if name in {"SystemModule", "create_system_module"}: + from .module import SystemModule, create_system_module + + values = { + "SystemModule": SystemModule, + "create_system_module": create_system_module, + } + return values[name] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/iti_system/cli.py b/iti_system/cli.py index 3c2b201..8ffea4b 100644 --- a/iti_system/cli.py +++ b/iti_system/cli.py @@ -7,8 +7,11 @@ from pathlib import Path import click +from .__about__ import __version__ + @click.group() +@click.version_option(__version__, prog_name="iti-system") def iti_system_cli() -> None: """iTi-System commands.""" diff --git a/pyproject.toml b/pyproject.toml index f8e8ccd..4adfb2d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "iti-system" -version = "0.2.0" +dynamic = ["version"] description = "Reusable system-domain package for iTi-Flask applications." readme = "README.md" requires-python = ">=3.11" @@ -26,6 +26,9 @@ dev = [ [tool.setuptools.packages.find] include = ["iti_system*"] +[tool.setuptools.dynamic] +version = { attr = "iti_system.__about__.__version__" } + [tool.setuptools.package-data] iti_system = [ "migrations/versions/*.py", diff --git a/scripts/iti-system.sh b/scripts/iti-system.sh new file mode 100755 index 0000000..f0048bb --- /dev/null +++ b/scripts/iti-system.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env sh +set -eu + +ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +cd "$ROOT_DIR" + +PROJECT_VENV="$ROOT_DIR/.venv" +case "${VIRTUAL_ENV:-}" in + ""|"$PROJECT_VENV"|"$PROJECT_VENV/") + ;; + *) + unset VIRTUAL_ENV + ;; +esac + +show_help() { + cat <<'EOF' +iTi-System 开发脚本 + +用法: + ./scripts/iti-system.sh <命令> [参数] + +常用命令: + help 显示帮助 + install 安装开发依赖:uv sync --extra dev + test 运行测试:uv run pytest -q + release [版本] 发布系统包:测试、改版本、提交、打 tag、推送 +EOF +} + +command=${1:-help} +shift || true + +case "$command" in + help|-h|--help) + show_help + ;; + install) + uv sync --extra dev + ;; + test) + uv run pytest -q + ;; + release) + uv run python scripts/release.py "$@" + ;; + *) + echo "未知命令:$command" >&2 + echo >&2 + show_help >&2 + exit 2 + ;; +esac diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 0000000..1563f7d --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import re +import subprocess +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +ABOUT_FILE = ROOT / "iti_system" / "__about__.py" +README_FILE = ROOT / "README.md" + +VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$") +ABOUT_VERSION_RE = re.compile(r'(__version__\s*=\s*")([^"]+)(")') +README_DEP_RE = re.compile( + r"(iti-system @ git\+ssh://git@example\.com/iTi-System\.git@)v\d+\.\d+\.\d+" +) + + +def run(cmd: list[str], *, capture_output: bool = False) -> subprocess.CompletedProcess[str]: + return subprocess.run( + cmd, + cwd=ROOT, + text=True, + capture_output=capture_output, + check=True, + ) + + +def normalize_version(value: str) -> str: + version = value[1:] if value.startswith("v") else value + if not VERSION_RE.fullmatch(version): + raise SystemExit(f"invalid version: {value}") + return version + + +def version_tuple(version: str) -> tuple[int, int, int]: + major, minor, patch = version.split(".") + return int(major), int(minor), int(patch) + + +def bump_patch(version: str) -> str: + major, minor, patch = version_tuple(version) + return f"{major}.{minor}.{patch + 1}" + + +def read_current_version() -> str: + text = ABOUT_FILE.read_text(encoding="utf-8") + match = ABOUT_VERSION_RE.search(text) + if match is None: + raise SystemExit(f"version not found in {ABOUT_FILE}") + return match.group(2) + + +def write_about_version(version: str) -> None: + text = ABOUT_FILE.read_text(encoding="utf-8") + new_text, count = ABOUT_VERSION_RE.subn(rf"\g<1>{version}\g<3>", text, count=1) + if count != 1: + raise SystemExit(f"version line not updated in {ABOUT_FILE}") + ABOUT_FILE.write_text(new_text, encoding="utf-8") + + +def write_readme_tags(version: str) -> None: + text = README_FILE.read_text(encoding="utf-8") + new_text, count = README_DEP_RE.subn(rf"\g<1>v{version}", text) + if count != 1: + raise SystemExit(f"dependency examples not updated in {README_FILE}") + README_FILE.write_text(new_text, encoding="utf-8") + + +def ensure_clean_tree() -> None: + result = run(["git", "status", "--porcelain"], capture_output=True) + if result.stdout.strip(): + raise SystemExit("working tree is not clean") + + +def ensure_branch() -> str: + result = run(["git", "symbolic-ref", "--quiet", "--short", "HEAD"], capture_output=True) + branch = result.stdout.strip() + if not branch: + raise SystemExit("release requires a branch checkout") + return branch + + +def ensure_tag_absent(tag: str) -> None: + result = subprocess.run( + ["git", "rev-parse", "--verify", "--quiet", f"refs/tags/{tag}"], + cwd=ROOT, + text=True, + capture_output=True, + ) + if result.returncode == 0: + raise SystemExit(f"tag already exists: {tag}") + + +def main() -> int: + parser = argparse.ArgumentParser(description="Release the iTi-System package") + parser.add_argument( + "version", + nargs="?", + help="target version, for example 0.2.1 or v0.2.1; defaults to patch bump", + ) + args = parser.parse_args() + + current_version = read_current_version() + target_version = normalize_version(args.version) if args.version else bump_patch(current_version) + target_tag = f"v{target_version}" + + if target_version == current_version: + raise SystemExit(f"version already set to {target_version}") + if version_tuple(target_version) <= version_tuple(current_version): + raise SystemExit(f"target version must be newer than {current_version}") + + ensure_clean_tree() + ensure_branch() + ensure_tag_absent(target_tag) + run(["uv", "run", "pytest", "-q"]) + + write_about_version(target_version) + write_readme_tags(target_version) + + run(["git", "add", "iti_system/__about__.py", "README.md"]) + run(["git", "commit", "-m", f"chore: release {target_tag}"]) + run(["git", "tag", "-a", target_tag, "-m", f"release {target_tag}"]) + + branch = ensure_branch() + run(["git", "push", "origin", branch, target_tag]) + + print(f"released {target_tag}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_modules.py b/tests/test_modules.py index 1a1dd8b..61ee607 100644 --- a/tests/test_modules.py +++ b/tests/test_modules.py @@ -1,6 +1,9 @@ from __future__ import annotations +from importlib.metadata import version + from iti_system import SystemModule, create_system_module +from iti_system.__about__ import __version__ def test_create_system_module_returns_full_system_module(): @@ -8,3 +11,7 @@ def test_create_system_module_returns_full_system_module(): assert isinstance(module, SystemModule) assert module.name == "iti_system" + + +def test_package_exports_version(): + assert __version__ == version("iti-system")