feat: framework system 独立更新

main
NoahLan 1 week ago
parent df17eccd01
commit 2afc49d61c

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

@ -9,7 +9,7 @@ import sys
import tomllib
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Sequence
from typing import Any, Mapping, Sequence
DEFAULT_FRAMEWORK_GIT = os.environ.get(
"ITICLI_FRAMEWORK_GIT",
@ -20,10 +20,6 @@ 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"}
@ -251,35 +247,49 @@ def handle_release(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"})
code = update_package("framework", args.tag)
ctx = require_project({"business", "system"})
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:
return code
if ctx.has_system:
return update_package("system", args.tag)
if "iti-system" in updates:
return sync_system_migrations(ctx)
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":
def resolve_update_packages(ctx: ProjectContext, target: str, tag_arg: str | None) -> dict[str, str]:
if ctx.kind == "system":
if target not in {"framework", "all"}:
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")
tag = normalize_git_tag(tag_arg) if tag_arg else latest_git_tag(git_url)
update_pyproject_git_tag(ctx.root, package, tag)
if target == "framework":
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:
return code
if package == "iti-system":
return sync_system_migrations(ctx)
return 0
def update_sync_cmd(ctx: ProjectContext, updates: Mapping[str, str]) -> list[str]:
cmd = ["uv", "sync", "--extra", "dev"]
if ctx.kind == "business" and ctx.has_system:
cmd.extend(["--no-sources-package", "iti-flask"])
for package in updates:
cmd.extend(["--upgrade-package", package])
return cmd
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:
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():
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))
parse_version(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]
if tag not in seen:
tags.append(tag)
seen.add(tag)
return tags
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:
def update_pyproject_git_tags(root: Path, updates: Mapping[str, str]) -> None:
path = root / "pyproject.toml"
lines = path.read_text(encoding="utf-8").splitlines()
updated = False
updated = {package: False for package in updates}
for index, line in enumerate(lines):
for package, tag in updates.items():
dependency_line = rewrite_dependency_git_line(line, package, tag)
if dependency_line is not None:
lines[index] = dependency_line
updated = True
continue
updated[package] = True
break
else:
for package, tag in updates.items():
source_line = rewrite_uv_source_git_line(line, package, tag)
if source_line is not None:
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")
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:

@ -9,13 +9,17 @@ from iticli.cli import (
alembic_base_cmd,
build_parser,
detect_project,
ensure_uv_no_sources_package,
extract_message,
find_optional_extra,
git_tags_from_refs,
has_package,
latest_git_tag_from_refs,
normalize_version,
parse_run_args,
update_pyproject_git_tag,
resolve_update_packages,
update_sync_cmd,
update_pyproject_git_tags,
version_is_newer,
)
@ -169,6 +173,29 @@ dddd refs/tags/not-a-version
"""
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:
@ -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_tag(tmp_path, "iti-system", "v0.2.6")
update_pyproject_git_tags(
tmp_path,
{
"iti-flask": "v0.2.5",
"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
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