You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

405 lines
12 KiB
Python

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