chore: update self

main
NoahLan 1 week ago
parent 1688647421
commit df17eccd01

@ -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` |

@ -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:
def handle_update(args: argparse.Namespace) -> int:
if args.target == "self":
return update_self()
if args.target == "all":
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)
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)
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:

@ -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

Loading…
Cancel
Save