from __future__ import annotations import os from pathlib import Path import pytest from iticli import cli as cli_module from iticli.cli import ( CliError, ProjectContext, alembic_base_cmd, build_run_cmd, build_parser, detect_project, ensure_env_file, ensure_uv_no_sources_package, extract_env_name, extract_message, find_optional_extra, git_tags_from_refs, has_package, latest_git_tag_from_refs, merge_path_env, normalize_version, parse_run_args, project_env, resolve_executable, resolve_update_packages, update_sync_cmd, update_pyproject_git_tags, version_is_newer, ) def write(path: Path, text: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(text, encoding="utf-8") def test_detect_framework_project(tmp_path: Path) -> None: write( tmp_path / "pyproject.toml", """ [project] name = "iti-flask" dependencies = [] """, ) write(tmp_path / "iti" / "app.py", "") write(tmp_path / "copier.yml", "") ctx = detect_project(tmp_path) assert ctx.kind == "framework" def test_detect_business_project(tmp_path: Path) -> None: write( tmp_path / "pyproject.toml", """ [project] name = "demo" dependencies = [ "iti-flask @ git+https://git.noahlan.cn/iti-framework/iTi-Flask.git@v0.2.4", "iti-system @ git+https://git.noahlan.cn/iti-framework/iTi-System.git@v0.2.4", ] """, ) write(tmp_path / "main.py", "") (tmp_path / "app").mkdir() write(tmp_path / "migrations" / "alembic.ini", "") ctx = detect_project(tmp_path) assert ctx.kind == "business" assert ctx.has_system is True def test_detect_system_project(tmp_path: Path) -> None: write( tmp_path / "pyproject.toml", """ [project] name = "iti-system" dependencies = ["iti-flask"] """, ) write(tmp_path / "iti_system" / "module.py", "") write(tmp_path / "iti_system" / "cli.py", "") ctx = detect_project(tmp_path) assert ctx.kind == "system" def test_detect_from_child_directory(tmp_path: Path) -> None: write( tmp_path / "pyproject.toml", """ [project] name = "demo" dependencies = ["iti-flask @ git+https://example.test/iTi-Flask.git"] """, ) write(tmp_path / "main.py", "") child = tmp_path / "app" / "modules" child.mkdir(parents=True) write(tmp_path / "migrations" / "alembic.ini", "") ctx = detect_project(child) assert ctx.root == tmp_path assert ctx.kind == "business" def test_has_package_reads_uv_sources() -> None: pyproject = { "project": {"dependencies": []}, "tool": {"uv": {"sources": {"iti-flask": {"git": "https://example.test/repo.git"}}}}, } assert has_package(pyproject, "iti-flask") is True def test_find_optional_extra_prefers_project_extra_name() -> None: pyproject = {"project": {"optional-dependencies": {"odbc": [], "dev": []}}} assert find_optional_extra(pyproject, ["odbc", "erp"]) == "odbc" def test_business_alembic_uses_template_config(tmp_path: Path) -> None: ctx = ProjectContext(tmp_path, "business", {}, False) assert alembic_base_cmd(ctx) == [ "uv", "run", "python", "-m", "alembic", "-c", "migrations/alembic.ini", ] def test_framework_alembic_uses_default_config(tmp_path: Path) -> None: ctx = ProjectContext(tmp_path, "framework", {}, False) assert alembic_base_cmd(ctx) == ["uv", "run", "python", "-m", "alembic"] def test_parse_run_args_accepts_env_and_port() -> None: assert parse_run_args("test", "9000") == ("test", "9000") assert parse_run_args("9000", None) == ("dev", "9000") assert parse_run_args("default", None) == ("dev", "8000") def test_build_run_cmd_uses_python_module() -> None: framework = ProjectContext(Path("."), "framework", {}, False) business = ProjectContext(Path("."), "business", {}, False) assert build_run_cmd(framework, "8000")[:5] == ["uv", "run", "python", "-m", "uvicorn"] assert "iti.app:create_app" in build_run_cmd(framework, "8000") assert "main:app" in build_run_cmd(business, "8000") def test_extract_message_accepts_remainder_flags() -> None: rest = ["-m", "alice add order table", "--head", "head"] assert extract_message(rest) == "alice add order table" assert rest == ["--head", "head"] def test_extract_env_name_accepts_remainder_flags() -> None: rest = ["--env", "prod", "--head", "head"] assert extract_env_name(rest) == "prod" 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_parser_accepts_create_database_dialect() -> None: parser = build_parser() args = parser.parse_args(["create", "--database", "postgresql", "demo"]) assert args.command == "create" assert args.database == "postgresql" def test_parser_accepts_migrate_env() -> None: parser = build_parser() args = parser.parse_args(["migrate", "--env", "prod"]) assert args.command == "migrate" assert args.env_name == "prod" def test_create_uses_uv_tool_run_copier(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: calls = [] def fake_run(cmd, cwd, extra_env=None): calls.append((cmd, cwd, extra_env)) return 0 monkeypatch.chdir(tmp_path) monkeypatch.setattr(cli_module, "run", fake_run) args = build_parser().parse_args(["create", "--database", "postgresql", "demo"]) assert args.handler(args) == 0 cmd, cwd, extra_env = calls[0] assert cmd[:5] == ["uv", "tool", "run", "copier", "copy"] assert ["-d", "database_dialect=postgresql"] == cmd[-2:] assert cwd == tmp_path assert extra_env is None def test_template_check_uses_uv_tool_run_copier(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: calls = [] ctx = ProjectContext(tmp_path, "business", {}, False) def fake_run(cmd, cwd, extra_env=None): calls.append((cmd, cwd, extra_env)) return 0 monkeypatch.setattr(cli_module, "require_project", lambda allowed=None: ctx) monkeypatch.setattr(cli_module, "run", fake_run) args = build_parser().parse_args(["template", "check"]) assert args.handler(args) == 0 cmd, cwd, extra_env = calls[0] assert cmd == ["uv", "tool", "run", "copier", "update", "--defaults", "--pretend", "--vcs-ref", "HEAD", str(tmp_path)] assert cwd == tmp_path assert extra_env is None def test_ensure_env_file_copies_example(tmp_path: Path) -> None: write(tmp_path / ".env.example", "APP_ENV=prod\n") ensure_env_file(tmp_path) assert (tmp_path / ".env").read_text(encoding="utf-8") == "APP_ENV=prod\n" def test_docker_creates_env_file(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: calls = [] ctx = ProjectContext(tmp_path, "business", {}, False) write(tmp_path / ".env.example", "APP_ENV=prod\n") def fake_run(cmd, cwd, extra_env=None): calls.append((cmd, cwd, extra_env)) return 0 monkeypatch.setattr(cli_module, "require_project", lambda allowed=None: ctx) monkeypatch.setattr(cli_module, "run", fake_run) args = build_parser().parse_args(["docker", "up"]) assert args.handler(args) == 0 assert (tmp_path / ".env").read_text(encoding="utf-8") == "APP_ENV=prod\n" assert calls[0][0] == ["docker", "compose", "up", "-d", "--build"] def test_project_env_merges_pythonpath(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: monkeypatch.setenv("PYTHONPATH", "existing") env = project_env(tmp_path, {"PYTHONPATH": str(tmp_path)}) assert env["PYTHONPATH"] == os.pathsep.join([str(tmp_path), "existing"]) assert merge_path_env(str(tmp_path), "existing") == env["PYTHONPATH"] def test_resolve_executable_uses_shutil_which(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(cli_module.shutil, "which", lambda command: r"C:\Tools\uv.exe" if command == "uv" else None) assert resolve_executable("uv") == r"C:\Tools\uv.exe" with pytest.raises(CliError, match="command not found: git"): resolve_executable("git") def test_run_resolves_executable_before_subprocess(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: calls = [] def fake_resolve(command: str) -> str: return f"/resolved/{command}" def fake_subprocess_run(cmd, cwd, env, check): calls.append((cmd, cwd, env, check)) class Completed: returncode = 0 return Completed() monkeypatch.setattr(cli_module, "resolve_executable", fake_resolve) monkeypatch.setattr(cli_module.subprocess, "run", fake_subprocess_run) assert cli_module.run(["uv", "--version"], tmp_path) == 0 assert calls[0][0] == ["/resolved/uv", "--version"] assert calls[0][1] == str(tmp_path) def test_version_helpers() -> None: 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.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" 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: 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_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