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