From 2afc49d61cde9d58a3cd165019301fd654739d46 Mon Sep 17 00:00:00 2001 From: NoahLan <6995syu@163.com> Date: Sun, 17 May 2026 14:27:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20framework=20system=20=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 3 + iticli/cli.py | 158 ++++++++++++++++++++++++++++++++-------------- tests/test_cli.py | 56 +++++++++++++++- 3 files changed, 168 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index d82bc26..64ce4c3 100644 --- a/README.md +++ b/README.md @@ -70,6 +70,9 @@ iticli docker logs iticli docker down ``` +`framework` 和 `system` 独立更新。 +带 `iti-system` 的业务项目会以项目顶层声明的 `iti-flask` 为准。 + CLI 自身: ```bash diff --git a/iticli/cli.py b/iticli/cli.py index b629501..746a3ad 100644 --- a/iticli/cli.py +++ b/iticli/cli.py @@ -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,37 +247,51 @@ 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) - if code: - return code - 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") - - 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) + 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 package == "iti-system": + if "iti-system" in updates: return sync_system_migrations(ctx) return 0 +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") + 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") + + 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}") + + +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: ctx = require_project({"business"}) cmd = ["uvx", "copier", "update", "--defaults", "--vcs-ref", "HEAD", str(ctx.root)] @@ -640,47 +650,103 @@ 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): - dependency_line = rewrite_dependency_git_line(line, package, tag) - if dependency_line is not None: - lines[index] = dependency_line - updated = True - continue + 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[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[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") - 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}") +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: stripped = line.lstrip() indent = line[: len(line) - len(stripped)] diff --git a/tests/test_cli.py b/tests/test_cli.py index 2c4fb61..5f59260 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -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