feat: framework system 独立更新

main
NoahLan 1 week ago
parent df17eccd01
commit 2afc49d61c

@ -70,6 +70,9 @@ iticli docker logs
iticli docker down iticli docker down
``` ```
`framework``system` 独立更新。
`iti-system` 的业务项目会以项目顶层声明的 `iti-flask` 为准。
CLI 自身: CLI 自身:
```bash ```bash

@ -9,7 +9,7 @@ import sys
import tomllib import tomllib
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Sequence from typing import Any, Mapping, Sequence
DEFAULT_FRAMEWORK_GIT = os.environ.get( DEFAULT_FRAMEWORK_GIT = os.environ.get(
"ITICLI_FRAMEWORK_GIT", "ITICLI_FRAMEWORK_GIT",
@ -20,10 +20,6 @@ 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"}
@ -251,35 +247,49 @@ def handle_release(args: argparse.Namespace) -> int:
def handle_update(args: argparse.Namespace) -> int: def handle_update(args: argparse.Namespace) -> int:
if args.target == "self": if args.target == "self":
return update_self() return update_self()
if args.target == "all":
ctx = require_project({"business"}) ctx = require_project({"business", "system"})
code = update_package("framework", args.tag) updates = resolve_update_packages(ctx, args.target, args.tag)
update_pyproject_git_tags(ctx.root, updates)
if ctx.kind == "business" and ctx.has_system:
ensure_uv_no_sources_package(ctx.root, "iti-flask")
code = run(update_sync_cmd(ctx, updates), ctx.root)
if code: if code:
return code return code
if ctx.has_system: if "iti-system" in updates:
return update_package("system", args.tag) return sync_system_migrations(ctx)
return 0 return 0
return update_package(args.target, args.tag)
def update_package(target: str, tag_arg: str | None) -> int: def resolve_update_packages(ctx: ProjectContext, target: str, tag_arg: str | None) -> dict[str, str]:
package, git_url = UPDATE_PACKAGES[target] if ctx.kind == "system":
ctx = require_project({"business", "system"}) if target not in {"framework", "all"}:
if package == "iti-system":
if ctx.kind != "business":
raise CliError("system package can only be updated from a business project") raise CliError("system package can only be updated from a business project")
if not ctx.has_system: return {"iti-flask": normalize_git_tag(tag_arg) if tag_arg else latest_git_tag(DEFAULT_FRAMEWORK_GIT)}
if target == "system" and 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")
tag = normalize_git_tag(tag_arg) if tag_arg else latest_git_tag(git_url) if target == "framework":
update_pyproject_git_tag(ctx.root, package, tag) return {"iti-flask": normalize_git_tag(tag_arg) if tag_arg else latest_git_tag(DEFAULT_FRAMEWORK_GIT)}
if target == "system":
return {"iti-system": normalize_git_tag(tag_arg) if tag_arg else latest_git_tag(DEFAULT_SYSTEM_GIT)}
if target == "all":
updates = {"iti-flask": normalize_git_tag(tag_arg) if tag_arg else latest_git_tag(DEFAULT_FRAMEWORK_GIT)}
if ctx.has_system:
updates["iti-system"] = normalize_git_tag(tag_arg) if tag_arg else latest_git_tag(DEFAULT_SYSTEM_GIT)
return updates
raise CliError(f"unknown update target: {target}")
code = run(["uv", "sync", "--extra", "dev", "--upgrade-package", package], ctx.root)
if code: def update_sync_cmd(ctx: ProjectContext, updates: Mapping[str, str]) -> list[str]:
return code cmd = ["uv", "sync", "--extra", "dev"]
if package == "iti-system": if ctx.kind == "business" and ctx.has_system:
return sync_system_migrations(ctx) cmd.extend(["--no-sources-package", "iti-flask"])
return 0 for package in updates:
cmd.extend(["--upgrade-package", package])
return cmd
def handle_template(args: argparse.Namespace) -> int: def handle_template(args: argparse.Namespace) -> int:
@ -640,45 +650,101 @@ def latest_git_tag(git_url: str) -> str:
def latest_git_tag_from_refs(refs: str, git_url: str = "remote") -> str: def latest_git_tag_from_refs(refs: str, git_url: str = "remote") -> str:
tags: list[tuple[tuple[int, int, int], str]] = [] tags = git_tags_from_refs(refs)
if not tags:
raise CliError(f"no semver tags found in {git_url}")
return max(tags, key=parse_version)
def git_tags_from_refs(refs: str) -> list[str]:
tags: list[str] = []
seen: set[str] = set()
for line in refs.splitlines(): for line in refs.splitlines():
parts = line.split() parts = line.split()
if len(parts) != 2 or parts[1].endswith("^{}"): if len(parts) != 2 or parts[1].endswith("^{}"):
continue continue
tag = parts[1].rsplit("/", 1)[-1] tag = parts[1].rsplit("/", 1)[-1]
try: try:
tags.append((parse_version(tag), tag)) parse_version(tag)
except CliError: except CliError:
continue continue
if not tags: if tag not in seen:
raise CliError(f"no semver tags found in {git_url}") tags.append(tag)
return max(tags, key=lambda item: item[0])[1] seen.add(tag)
return tags
def normalize_git_tag(raw: str) -> str: def normalize_git_tag(raw: str) -> str:
return f"v{normalize_version(raw)}" return f"v{normalize_version(raw)}"
def update_pyproject_git_tag(root: Path, package: str, tag: str) -> None: def update_pyproject_git_tags(root: Path, updates: Mapping[str, str]) -> None:
path = root / "pyproject.toml" path = root / "pyproject.toml"
lines = path.read_text(encoding="utf-8").splitlines() lines = path.read_text(encoding="utf-8").splitlines()
updated = False updated = {package: False for package in updates}
for index, line in enumerate(lines): for index, line in enumerate(lines):
for package, tag in updates.items():
dependency_line = rewrite_dependency_git_line(line, package, tag) dependency_line = rewrite_dependency_git_line(line, package, tag)
if dependency_line is not None: if dependency_line is not None:
lines[index] = dependency_line lines[index] = dependency_line
updated = True updated[package] = True
continue break
else:
for package, tag in updates.items():
source_line = rewrite_uv_source_git_line(line, package, tag) source_line = rewrite_uv_source_git_line(line, package, tag)
if source_line is not None: if source_line is not None:
lines[index] = source_line lines[index] = source_line
updated = True updated[package] = True
break
missing = [package for package, is_updated in updated.items() if not is_updated]
if missing:
raise CliError(f"{', '.join(missing)} git dependency not found in {path}")
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
if not updated:
raise CliError(f"{package} git dependency not found in {path}") def ensure_uv_no_sources_package(root: Path, package: str) -> None:
path = root / "pyproject.toml"
lines = path.read_text(encoding="utf-8").splitlines()
section_start = find_toml_section(lines, "[tool.uv]")
if section_start is None:
source_start = find_toml_section(lines, "[tool.uv.sources]")
insert_at = source_start if source_start is not None else len(lines)
block = ["[tool.uv]", f'no-sources-package = ["{package}"]', ""]
lines[insert_at:insert_at] = block
path.write_text("\n".join(lines) + "\n", encoding="utf-8") path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return
section_end = next_toml_section(lines, section_start + 1)
for index in range(section_start + 1, section_end):
line = lines[index].strip()
if not line.startswith("no-sources-package"):
continue
if re.search(rf'"{re.escape(package)}"|\'{re.escape(package)}\'', line):
return
if line.endswith("]"):
lines[index] = f'{lines[index][:-1]}, "{package}"]'
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return
raise CliError("cannot update multi-line tool.uv.no-sources-package")
lines.insert(section_end, f'no-sources-package = ["{package}"]')
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def find_toml_section(lines: Sequence[str], section: str) -> int | None:
for index, line in enumerate(lines):
if line.strip() == section:
return index
return None
def next_toml_section(lines: Sequence[str], start: int) -> int:
for index in range(start, len(lines)):
if re.match(r"\s*\[.+]\s*$", lines[index]):
return index
return len(lines)
def rewrite_dependency_git_line(line: str, package: str, tag: str) -> str | None: def rewrite_dependency_git_line(line: str, package: str, tag: str) -> str | None:

@ -9,13 +9,17 @@ from iticli.cli import (
alembic_base_cmd, alembic_base_cmd,
build_parser, build_parser,
detect_project, detect_project,
ensure_uv_no_sources_package,
extract_message, extract_message,
find_optional_extra, find_optional_extra,
git_tags_from_refs,
has_package, has_package,
latest_git_tag_from_refs, latest_git_tag_from_refs,
normalize_version, normalize_version,
parse_run_args, parse_run_args,
update_pyproject_git_tag, resolve_update_packages,
update_sync_cmd,
update_pyproject_git_tags,
version_is_newer, version_is_newer,
) )
@ -169,6 +173,29 @@ dddd refs/tags/not-a-version
""" """
assert latest_git_tag_from_refs(refs) == "v0.2.10" assert latest_git_tag_from_refs(refs) == "v0.2.10"
assert git_tags_from_refs(refs) == ["v0.2.9", "v0.2.10"]
def test_resolve_update_packages_keeps_framework_and_system_independent() -> None:
ctx = ProjectContext(Path("."), "business", {}, True)
assert resolve_update_packages(ctx, "framework", "v0.2.5") == {"iti-flask": "v0.2.5"}
assert resolve_update_packages(ctx, "system", "0.3.2") == {"iti-system": "v0.3.2"}
def test_update_sync_cmd_ignores_transitive_framework_source_for_system_projects() -> None:
ctx = ProjectContext(Path("."), "business", {}, True)
assert update_sync_cmd(ctx, {"iti-flask": "v0.2.5"}) == [
"uv",
"sync",
"--extra",
"dev",
"--no-sources-package",
"iti-flask",
"--upgrade-package",
"iti-flask",
]
def test_update_pyproject_git_tag_rewrites_dependency_and_uv_source(tmp_path: Path) -> None: def test_update_pyproject_git_tag_rewrites_dependency_and_uv_source(tmp_path: Path) -> None:
@ -187,11 +214,34 @@ 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_tags(
update_pyproject_git_tag(tmp_path, "iti-system", "v0.2.6") tmp_path,
{
"iti-flask": "v0.2.5",
"iti-system": "v0.2.6",
},
)
text = (tmp_path / "pyproject.toml").read_text(encoding="utf-8") 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-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-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-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 assert 'iti-system = { git = "https://example.test/iTi-System.git", tag = "v0.2.6" }' in text
def test_ensure_uv_no_sources_package_inserts_tool_uv_before_sources(tmp_path: Path) -> None:
write(
tmp_path / "pyproject.toml",
"""
[project]
name = "demo"
[tool.uv.sources]
iti-flask = { git = "https://example.test/iTi-Flask.git", tag = "v0.2.5" }
""",
)
ensure_uv_no_sources_package(tmp_path, "iti-flask")
text = (tmp_path / "pyproject.toml").read_text(encoding="utf-8")
assert "[tool.uv]\nno-sources-package = [\"iti-flask\"]\n\n[tool.uv.sources]" in text

Loading…
Cancel
Save