chore: update self

main
NoahLan 1 week ago
parent 1688647421
commit df17eccd01

@ -56,9 +56,9 @@ iticli migrate revision "alice add order table"
业务项目: 业务项目:
```bash ```bash
iticli sync flask iticli update framework
iticli sync system iticli update system
iticli sync all iticli update all
iticli template check iticli template check
iticli template update iticli template update
iticli init iticli init
@ -70,6 +70,12 @@ iticli docker logs
iticli docker down iticli docker down
``` ```
CLI 自身:
```bash
iticli update self
```
框架项目: 框架项目:
```bash ```bash
@ -107,8 +113,8 @@ iticli create --local ../my-business-app
| `./iti.sh make-app ../app app` | `iticli create app` | | `./iti.sh make-app ../app app` | `iticli create app` |
| `./iti.sh make-system-app ../app app` | `iticli create --with-system app` | | `./iti.sh make-system-app ../app app` | `iticli create --with-system app` |
| `./iti.sh release` | `iticli release` | | `./iti.sh release` | `iticli release` |
| `./app.sh framework-sync` | `iticli sync flask` | | `./app.sh framework-sync` | `iticli update framework` |
| `./app.sh system-sync` | `iticli sync system` | | `./app.sh system-sync` | `iticli update system` |
| `./app.sh template-check` | `iticli template check` | | `./app.sh template-check` | `iticli template check` |
| `./app.sh template-update` | `iticli template update` | | `./app.sh template-update` | `iticli template update` |
| `./app.sh init` | `iticli init` | | `./app.sh init` | `iticli init` |

@ -3,6 +3,7 @@ from __future__ import annotations
import argparse import argparse
import os import os
import re import re
import shutil
import subprocess import subprocess
import sys import sys
import tomllib import tomllib
@ -19,6 +20,10 @@ DEFAULT_SYSTEM_GIT = os.environ.get(
"https://git.noahlan.cn/iti-framework/iTi-System.git", "https://git.noahlan.cn/iti-framework/iTi-System.git",
) )
DEFAULT_COPIER_REF = os.environ.get("ITICLI_COPIER_REF", "HEAD") 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"} 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.add_argument("version", nargs="?", help="target version, defaults to next patch")
release_parser.set_defaults(handler=handle_release) release_parser.set_defaults(handler=handle_release)
sync_parser = subparsers.add_parser("sync", help="sync framework or system package") update_parser = subparsers.add_parser("update", help="update iticli or framework packages")
sync_parser.add_argument("target", choices=["flask", "system", "all"], nargs="?", default="all") update_parser.add_argument("target", choices=["self", "framework", "system", "all"])
sync_parser.set_defaults(handler=handle_sync) 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_parser = subparsers.add_parser("template", help="check or update Copier template")
template_subparsers = template_parser.add_subparsers(dest="template_action", required=True) 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) return release_framework(ctx.root, args.version)
def handle_sync(args: argparse.Namespace) -> int: def handle_update(args: argparse.Namespace) -> int:
ctx = require_project({"business"}) if args.target == "self":
if args.target in {"flask", "all"}: return update_self()
cmd = ["uv", "sync", "--extra", "dev", "--upgrade-package", "iti-flask"] if args.target == "all":
if args.target == "all" and ctx.has_system: ctx = require_project({"business"})
cmd.extend(["--upgrade-package", "iti-system"]) code = update_package("framework", args.tag)
code = run(cmd, ctx.root)
if code: if code:
return 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: if not ctx.has_system:
raise CliError("current business project does not depend on iti-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: tag = normalize_git_tag(tag_arg) if tag_arg else latest_git_tag(git_url)
return code 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 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: def run(cmd: Sequence[str], cwd: Path, extra_env: dict[str, str] | None = None) -> int:
env = project_env(cwd, extra_env) env = project_env(cwd, extra_env)
completed = subprocess.run(list(cmd), cwd=str(cwd), env=env, check=False) 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 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: def read_current_version(about_file: Path) -> str:
match = re.search(r'^__version__\s*=\s*"([^"]+)"', about_file.read_text(encoding="utf-8"), re.MULTILINE) match = re.search(r'^__version__\s*=\s*"([^"]+)"', about_file.read_text(encoding="utf-8"), re.MULTILINE)
if not match: if not match:

@ -2,15 +2,20 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
import pytest
from iticli.cli import ( from iticli.cli import (
ProjectContext, ProjectContext,
alembic_base_cmd, alembic_base_cmd,
build_parser,
detect_project, detect_project,
extract_message, extract_message,
find_optional_extra, find_optional_extra,
has_package, has_package,
latest_git_tag_from_refs,
normalize_version, normalize_version,
parse_run_args, parse_run_args,
update_pyproject_git_tag,
version_is_newer, version_is_newer,
) )
@ -136,7 +141,57 @@ def test_extract_message_accepts_remainder_flags() -> None:
assert rest == ["--head", "head"] 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: def test_version_helpers() -> None:
assert normalize_version("v1.2.3") == "1.2.3" 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.4") is True
assert version_is_newer("1.2.3", "1.2.3") is False 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