From df17eccd012a661835293a802fa86597d7bd7d40 Mon Sep 17 00:00:00 2001 From: NoahLan <6995syu@163.com> Date: Sun, 17 May 2026 13:59:32 +0800 Subject: [PATCH] chore: update self --- README.md | 16 +++-- iticli/cli.py | 163 ++++++++++++++++++++++++++++++++++++++++++---- tests/test_cli.py | 55 ++++++++++++++++ 3 files changed, 215 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 3472657..d82bc26 100644 --- a/README.md +++ b/README.md @@ -56,9 +56,9 @@ iticli migrate revision "alice add order table" 业务项目: ```bash -iticli sync flask -iticli sync system -iticli sync all +iticli update framework +iticli update system +iticli update all iticli template check iticli template update iticli init @@ -70,6 +70,12 @@ iticli docker logs iticli docker down ``` +CLI 自身: + +```bash +iticli update self +``` + 框架项目: ```bash @@ -107,8 +113,8 @@ iticli create --local ../my-business-app | `./iti.sh make-app ../app app` | `iticli create app` | | `./iti.sh make-system-app ../app app` | `iticli create --with-system app` | | `./iti.sh release` | `iticli release` | -| `./app.sh framework-sync` | `iticli sync flask` | -| `./app.sh system-sync` | `iticli sync system` | +| `./app.sh framework-sync` | `iticli update framework` | +| `./app.sh system-sync` | `iticli update system` | | `./app.sh template-check` | `iticli template check` | | `./app.sh template-update` | `iticli template update` | | `./app.sh init` | `iticli init` | diff --git a/iticli/cli.py b/iticli/cli.py index b29415a..b629501 100644 --- a/iticli/cli.py +++ b/iticli/cli.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse import os import re +import shutil import subprocess import sys import tomllib @@ -19,6 +20,10 @@ DEFAULT_SYSTEM_GIT = os.environ.get( "https://git.noahlan.cn/iti-framework/iTi-System.git", ) DEFAULT_COPIER_REF = os.environ.get("ITICLI_COPIER_REF", "HEAD") +UPDATE_PACKAGES = { + "framework": ("iti-flask", DEFAULT_FRAMEWORK_GIT), + "system": ("iti-system", DEFAULT_SYSTEM_GIT), +} ENV_NAMES = {"dev", "test", "prod", "default"} @@ -99,9 +104,10 @@ def build_parser() -> argparse.ArgumentParser: release_parser.add_argument("version", nargs="?", help="target version, defaults to next patch") release_parser.set_defaults(handler=handle_release) - sync_parser = subparsers.add_parser("sync", help="sync framework or system package") - sync_parser.add_argument("target", choices=["flask", "system", "all"], nargs="?", default="all") - sync_parser.set_defaults(handler=handle_sync) + update_parser = subparsers.add_parser("update", help="update iticli or framework packages") + update_parser.add_argument("target", choices=["self", "framework", "system", "all"]) + update_parser.add_argument("--tag", help="target Git tag, defaults to latest semver tag") + update_parser.set_defaults(handler=handle_update) template_parser = subparsers.add_parser("template", help="check or update Copier template") template_subparsers = template_parser.add_subparsers(dest="template_action", required=True) @@ -242,21 +248,37 @@ def handle_release(args: argparse.Namespace) -> int: return release_framework(ctx.root, args.version) -def handle_sync(args: argparse.Namespace) -> int: - ctx = require_project({"business"}) - if args.target in {"flask", "all"}: - cmd = ["uv", "sync", "--extra", "dev", "--upgrade-package", "iti-flask"] - if args.target == "all" and ctx.has_system: - cmd.extend(["--upgrade-package", "iti-system"]) - code = run(cmd, ctx.root) +def handle_update(args: argparse.Namespace) -> int: + if args.target == "self": + return update_self() + if args.target == "all": + ctx = require_project({"business"}) + code = update_package("framework", args.tag) if code: return code - if args.target in {"system", "all"}: + if ctx.has_system: + return update_package("system", args.tag) + return 0 + return update_package(args.target, args.tag) + + +def update_package(target: str, tag_arg: str | None) -> int: + package, git_url = UPDATE_PACKAGES[target] + ctx = require_project({"business", "system"}) + if package == "iti-system": + if ctx.kind != "business": + raise CliError("system package can only be updated from a business project") if not ctx.has_system: raise CliError("current business project does not depend on iti-system") - code = run(["uv", "run", "iti-system", "migrations", "sync", "--target", "migrations/versions"], ctx.root) - if code: - return code + + tag = normalize_git_tag(tag_arg) if tag_arg else latest_git_tag(git_url) + update_pyproject_git_tag(ctx.root, package, tag) + + code = run(["uv", "sync", "--extra", "dev", "--upgrade-package", package], ctx.root) + if code: + return code + if package == "iti-system": + return sync_system_migrations(ctx) return 0 @@ -460,6 +482,22 @@ def seed_system(ctx: ProjectContext) -> int: ) +def update_self() -> int: + commands: list[list[str]] = [] + if shutil.which("uv"): + commands.append(["uv", "tool", "upgrade", "iti-flask-cli"]) + if shutil.which("pipx"): + commands.append(["pipx", "upgrade", "iti-flask-cli"]) + if not commands: + raise CliError("uv or pipx is required to update iticli") + + for cmd in commands: + code = run(cmd, Path.cwd()) + if code == 0: + return 0 + raise CliError("failed to update iticli with uv tool or pipx") + + def run(cmd: Sequence[str], cwd: Path, extra_env: dict[str, str] | None = None) -> int: env = project_env(cwd, extra_env) completed = subprocess.run(list(cmd), cwd=str(cwd), env=env, check=False) @@ -588,6 +626,103 @@ def git_output(root: Path, args: Sequence[str]) -> str: return completed.stdout +def latest_git_tag(git_url: str) -> str: + completed = subprocess.run( + ["git", "ls-remote", "--tags", git_url], + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + check=False, + ) + if completed.returncode: + raise CliError(completed.stderr.strip() or f"git ls-remote failed: {git_url}") + return latest_git_tag_from_refs(completed.stdout, git_url) + + +def latest_git_tag_from_refs(refs: str, git_url: str = "remote") -> str: + tags: list[tuple[tuple[int, int, int], str]] = [] + for line in refs.splitlines(): + parts = line.split() + if len(parts) != 2 or parts[1].endswith("^{}"): + continue + tag = parts[1].rsplit("/", 1)[-1] + try: + tags.append((parse_version(tag), tag)) + except CliError: + continue + if not tags: + raise CliError(f"no semver tags found in {git_url}") + return max(tags, key=lambda item: item[0])[1] + + +def normalize_git_tag(raw: str) -> str: + return f"v{normalize_version(raw)}" + + +def update_pyproject_git_tag(root: Path, package: str, tag: str) -> None: + path = root / "pyproject.toml" + lines = path.read_text(encoding="utf-8").splitlines() + updated = False + + for index, line in enumerate(lines): + dependency_line = rewrite_dependency_git_line(line, package, tag) + if dependency_line is not None: + lines[index] = dependency_line + updated = True + continue + + source_line = rewrite_uv_source_git_line(line, package, tag) + if source_line is not None: + lines[index] = source_line + updated = True + + if not updated: + raise CliError(f"{package} git dependency not found in {path}") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +def rewrite_dependency_git_line(line: str, package: str, tag: str) -> str | None: + stripped = line.lstrip() + indent = line[: len(line) - len(stripped)] + if not stripped.startswith(('"', "'")): + return None + + quote = stripped[0] + end = stripped.find(quote, 1) + if end < 0: + return None + + dependency = stripped[1:end] + name_part, separator, git_ref = dependency.partition("@ git+") + if not separator or dependency_package_name(name_part) != package: + return None + + git_url, _old_ref = split_git_url_ref(git_ref.strip()) + new_dependency = f"{name_part.rstrip()} @ git+{git_url}@{tag}" + return f"{indent}{quote}{new_dependency}{quote}{stripped[end + 1:]}" + + +def rewrite_uv_source_git_line(line: str, package: str, tag: str) -> str | None: + if not re.match(rf"^\s*{re.escape(package)}\s*=", line): + return None + match = re.search(r'git\s*=\s*"([^"]+)"', line) + if not match: + raise CliError(f"{package} source is not a git source") + indent = line[: len(line) - len(line.lstrip())] + return f'{indent}{package} = {{ git = "{match.group(1)}", tag = "{tag}" }}' + + +def dependency_package_name(name_part: str) -> str: + return re.split(r"\[|\s", name_part.strip(), maxsplit=1)[0].lower().replace("_", "-") + + +def split_git_url_ref(value: str) -> tuple[str, str | None]: + url, separator, ref = value.rpartition("@") + if separator and ref and "/" not in ref and ":" not in ref: + return url, ref + return value, None + + def read_current_version(about_file: Path) -> str: match = re.search(r'^__version__\s*=\s*"([^"]+)"', about_file.read_text(encoding="utf-8"), re.MULTILINE) if not match: diff --git a/tests/test_cli.py b/tests/test_cli.py index bdf0b87..2c4fb61 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -2,15 +2,20 @@ from __future__ import annotations from pathlib import Path +import pytest + from iticli.cli import ( ProjectContext, alembic_base_cmd, + build_parser, detect_project, extract_message, find_optional_extra, has_package, + latest_git_tag_from_refs, normalize_version, parse_run_args, + update_pyproject_git_tag, version_is_newer, ) @@ -136,7 +141,57 @@ def test_extract_message_accepts_remainder_flags() -> None: assert rest == ["--head", "head"] +def test_parser_exposes_update_instead_of_sync() -> None: + parser = build_parser() + + args = parser.parse_args(["update", "framework", "--tag", "v0.2.5"]) + + assert args.command == "update" + assert args.target == "framework" + with pytest.raises(SystemExit): + parser.parse_args(["sync", "flask"]) + with pytest.raises(SystemExit): + parser.parse_args(["update", "flask"]) + + def test_version_helpers() -> None: assert normalize_version("v1.2.3") == "1.2.3" assert version_is_newer("1.2.3", "1.2.4") is True assert version_is_newer("1.2.3", "1.2.3") is False + + +def test_latest_git_tag_from_refs_uses_highest_semver() -> None: + refs = """ +aaaa refs/tags/v0.2.9 +bbbb refs/tags/v0.2.10 +cccc refs/tags/v0.2.10^{} +dddd refs/tags/not-a-version +""" + + assert latest_git_tag_from_refs(refs) == "v0.2.10" + + +def test_update_pyproject_git_tag_rewrites_dependency_and_uv_source(tmp_path: Path) -> None: + write( + tmp_path / "pyproject.toml", + """ +[project] +dependencies = [ + "iti-flask[excel] @ git+https://example.test/iTi-Flask.git@241f1d9", + "iti-system @ git+https://example.test/iTi-System.git@v0.2.4", +] + +[tool.uv.sources] +iti-flask = { git = "https://example.test/iTi-Flask.git", rev = "241f1d9" } +iti-system = { git = "https://example.test/iTi-System.git", tag = "v0.2.4" } +""", + ) + + update_pyproject_git_tag(tmp_path, "iti-flask", "v0.2.5") + update_pyproject_git_tag(tmp_path, "iti-system", "v0.2.6") + + text = (tmp_path / "pyproject.toml").read_text(encoding="utf-8") + assert '"iti-flask[excel] @ git+https://example.test/iTi-Flask.git@v0.2.5",' in text + assert '"iti-system @ git+https://example.test/iTi-System.git@v0.2.6",' in text + assert 'iti-flask = { git = "https://example.test/iTi-Flask.git", tag = "v0.2.5" }' in text + assert 'iti-system = { git = "https://example.test/iTi-System.git", tag = "v0.2.6" }' in text