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.
378 lines
11 KiB
Python
378 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
import subprocess
|
|
|
|
import pytest
|
|
|
|
from iticli.cli import (
|
|
CliError,
|
|
ProjectContext,
|
|
alembic_base_cmd,
|
|
build_run_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,
|
|
normalize_worker_name,
|
|
parse_run_args,
|
|
resolve_worker_module,
|
|
resolve_update_packages,
|
|
run,
|
|
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_run_returns_130_after_keyboard_interrupt(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
class DummyProcess:
|
|
def __init__(self) -> None:
|
|
self.wait_calls: list[int | None] = []
|
|
|
|
def wait(self, timeout: int | None = None) -> int:
|
|
self.wait_calls.append(timeout)
|
|
if timeout is None:
|
|
raise KeyboardInterrupt
|
|
return 0
|
|
|
|
def terminate(self) -> None:
|
|
raise AssertionError("terminate should not be called after graceful child exit")
|
|
|
|
def kill(self) -> None:
|
|
raise AssertionError("kill should not be called after graceful child exit")
|
|
|
|
process = DummyProcess()
|
|
|
|
def fake_popen(cmd: list[str], cwd: str, env: dict[str, str]) -> DummyProcess:
|
|
assert cmd == ["demo"]
|
|
assert cwd == str(tmp_path)
|
|
assert env["PYTHONPATH"]
|
|
return process
|
|
|
|
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
|
|
|
assert run(["demo"], tmp_path, extra_env={"PYTHONPATH": "."}) == 130
|
|
assert process.wait_calls == [None, 30]
|
|
|
|
|
|
def test_run_terminates_child_when_interrupt_shutdown_times_out(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
class DummyProcess:
|
|
def __init__(self) -> None:
|
|
self.terminated = False
|
|
self.killed = False
|
|
|
|
def wait(self, timeout: int | None = None) -> int:
|
|
if timeout is None:
|
|
raise KeyboardInterrupt
|
|
if timeout == 30:
|
|
raise subprocess.TimeoutExpired(["demo"], timeout)
|
|
return 0
|
|
|
|
def terminate(self) -> None:
|
|
self.terminated = True
|
|
|
|
def kill(self) -> None:
|
|
self.killed = True
|
|
|
|
process = DummyProcess()
|
|
|
|
def fake_popen(cmd: list[str], cwd: str, env: dict[str, str]) -> DummyProcess:
|
|
return process
|
|
|
|
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
|
|
|
assert run(["demo"], tmp_path) == 130
|
|
assert process.terminated is True
|
|
assert process.killed is False
|
|
|
|
|
|
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_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_worker_command() -> None:
|
|
parser = build_parser()
|
|
|
|
args = parser.parse_args(["worker", "mq", "--", "--config", "dev"])
|
|
|
|
assert args.command == "worker"
|
|
assert args.name == "mq"
|
|
assert args.args == ["--", "--config", "dev"]
|
|
|
|
|
|
def test_normalize_worker_name_accepts_dash_alias() -> None:
|
|
assert normalize_worker_name("sync-order") == "sync_order"
|
|
|
|
|
|
def test_normalize_worker_name_rejects_invalid_name() -> None:
|
|
with pytest.raises(CliError):
|
|
normalize_worker_name("../mq")
|
|
|
|
|
|
def test_resolve_worker_module_prefers_module_worker(tmp_path: Path) -> None:
|
|
write(tmp_path / "app" / "modules" / "mq" / "worker.py", "")
|
|
ctx = ProjectContext(tmp_path, "business", {}, False)
|
|
|
|
assert resolve_worker_module(ctx, "mq") == "app.modules.mq.worker"
|
|
|
|
|
|
def test_resolve_worker_module_accepts_app_workers(tmp_path: Path) -> None:
|
|
write(tmp_path / "app" / "workers" / "sync_order.py", "")
|
|
ctx = ProjectContext(tmp_path, "business", {}, False)
|
|
|
|
assert resolve_worker_module(ctx, "sync_order") == "app.workers.sync_order"
|
|
|
|
|
|
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
|