* feat(infra): add check_pyproject and --repo flag to project-status * feat(infra): add repo flag to project-status ts wrapper * test(infra): add check_pyproject and repo flag tests * fix(infra): accept packages=["src"] as valid hatchling wheel config * fix(ci): apply ruff format to project-status.py and tests --------- Co-authored-by: opencode-agent <agent@opencode.local>
1259 lines
49 KiB
Python
1259 lines
49 KiB
Python
"""Tests for .opencode/scripts/project-status.py — architecture oracle.
|
|
|
|
All gh/git calls are mocked via monkeypatch on the module's ``run_cmd``
|
|
helper. Filesystem checks use ``tmp_path`` with ``monkeypatch`` on
|
|
``ps.REPO_ROOT`` so each test sees an isolated repo.
|
|
|
|
Covers: auto-detect (5 types + unknown), 7 check groups, format output
|
|
([OK]/[WARN]/[FAIL] + Итог + Рекомендации), exit codes (non-blocking vs
|
|
``--check`` strict), ``--fast`` skip of branch protection.
|
|
"""
|
|
|
|
import importlib.util
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
SCRIPT_PATH = Path(__file__).resolve().parent.parent / ".opencode" / "scripts" / "project-status.py"
|
|
spec = importlib.util.spec_from_file_location("project_status", SCRIPT_PATH)
|
|
ps = importlib.util.module_from_spec(spec)
|
|
sys.modules["project_status"] = ps
|
|
spec.loader.exec_module(ps)
|
|
|
|
|
|
GIT_REMOTE_MOCK: tuple[tuple[str, ...], tuple[int, str, str]] = (
|
|
("git", "remote"),
|
|
(0, "https://github.com/slaid098/opencode-config.git\n", ""),
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _isolate_repo(monkeypatch, tmp_path):
|
|
"""Point ``ps.REPO_ROOT`` at ``tmp_path`` and reset CONFIG for each test."""
|
|
monkeypatch.setattr(ps, "REPO_ROOT", tmp_path)
|
|
monkeypatch.setattr(ps, "CONFIG", dict(ps.DEFAULT_CONFIG))
|
|
|
|
|
|
def mock_run_cmd(responses: dict[tuple, tuple[int, str, str]]):
|
|
"""Factory: mock run_cmd matching by command prefix.
|
|
|
|
Automatically includes a ``git remote get-url origin`` mock so
|
|
``get_repo_full_name()`` works without extra boilerplate.
|
|
"""
|
|
merged = {GIT_REMOTE_MOCK[0]: GIT_REMOTE_MOCK[1], **responses}
|
|
|
|
def _mock(args: list[str]) -> tuple[int, str, str]:
|
|
for prefix, result in merged.items():
|
|
if tuple(args[: len(prefix)]) == tuple(prefix):
|
|
return result
|
|
return (1, "", f"unmocked call: {args}")
|
|
|
|
return _mock
|
|
|
|
|
|
def _tool_sections(
|
|
has_ruff: bool,
|
|
has_mypy: bool,
|
|
has_pytest: bool,
|
|
cov_source: list | None,
|
|
cov_fail: str | None,
|
|
) -> list[str]:
|
|
"""Build the [tool.*] section lines for pyproject.toml."""
|
|
lines: list[str] = []
|
|
if has_ruff:
|
|
lines.extend(["[tool.ruff]", 'target-version = "py312"', "line-length = 100", ""])
|
|
if has_mypy:
|
|
lines.extend(["[tool.mypy]", 'python_version = "3.12"', "strict = true", ""])
|
|
if has_pytest:
|
|
lines.append("[tool.pytest.ini_options]")
|
|
addopts = "--cov=src --cov-report=term-missing --timeout=120"
|
|
if cov_fail:
|
|
addopts += f" --cov-fail-under={cov_fail}"
|
|
lines.append(f'addopts = "{addopts}"')
|
|
lines.extend(['asyncio_mode = "auto"', 'testpaths = ["tests"]', ""])
|
|
if cov_source is not None:
|
|
lines.append("[tool.coverage.run]")
|
|
source_str = ", ".join(f'"{s}"' for s in cov_source)
|
|
lines.append(f"source = [{source_str}]")
|
|
lines.append("branch = true")
|
|
lines.append("")
|
|
lines.append("[tool.coverage.report]")
|
|
lines.append("exclude_lines = [")
|
|
lines.append(' "pragma: no cover",')
|
|
lines.append(' "if __name__ == .__main__.:",')
|
|
lines.append(' "if TYPE_CHECKING:",')
|
|
lines.append("]")
|
|
lines.append("")
|
|
return lines
|
|
|
|
|
|
def _write_pyproject(
|
|
tmp_path: Path,
|
|
*,
|
|
deps: list[str] | None = None,
|
|
scripts: dict | None = None,
|
|
has_ruff: bool = True,
|
|
has_mypy: bool = True,
|
|
has_pytest: bool = True,
|
|
cov_source: list | None = None,
|
|
cov_fail: str | None = None,
|
|
name: str = "test-repo",
|
|
description: str = "test repo for project-status",
|
|
requires_python: str = ">=3.12",
|
|
hatch_packages: list | None = None,
|
|
) -> None:
|
|
"""Write a minimal pyproject.toml with the requested [tool.*] sections."""
|
|
norm_name = (
|
|
ps._normalize_package_name(name)
|
|
if hasattr(ps, "_normalize_package_name")
|
|
else name.replace("-", "_")
|
|
)
|
|
lines = [
|
|
"[build-system]",
|
|
'requires = ["hatchling"]',
|
|
'build-backend = "hatchling.build"',
|
|
"",
|
|
"[project]",
|
|
f'name = "{name}"',
|
|
'version = "0.1.0"',
|
|
f'description = "{description}"',
|
|
f'requires-python = "{requires_python}"',
|
|
"",
|
|
"dependencies = [",
|
|
]
|
|
for d in deps or []:
|
|
lines.append(f' "{d}",')
|
|
lines.extend(["]", "", "[project.optional-dependencies]", "dev = ["])
|
|
if has_pytest:
|
|
lines.append(' "pytest>=8.0",')
|
|
if has_mypy:
|
|
lines.append(' "mypy>=1.10",')
|
|
if has_ruff:
|
|
lines.append(' "ruff>=0.5",')
|
|
lines.extend(["]", ""])
|
|
if scripts:
|
|
lines.append("[project.scripts]")
|
|
for k, v in scripts.items():
|
|
lines.append(f'{k} = "{v}"')
|
|
lines.append("")
|
|
# [tool.hatch.build.targets.wheel] packages — only emit if src/<pkg>/ exists
|
|
# or caller explicitly passes hatch_packages.
|
|
src_pkg = tmp_path / "src" / norm_name
|
|
if hatch_packages is not None:
|
|
lines.append("[tool.hatch.build.targets.wheel]")
|
|
pkgs_str = ", ".join(f'"{p}"' for p in hatch_packages)
|
|
lines.append(f"packages = [{pkgs_str}]")
|
|
lines.append("")
|
|
elif src_pkg.exists():
|
|
lines.append("[tool.hatch.build.targets.wheel]")
|
|
lines.append(f'packages = ["src/{norm_name}"]')
|
|
lines.append("")
|
|
lines.extend(_tool_sections(has_ruff, has_mypy, has_pytest, cov_source, cov_fail))
|
|
(tmp_path / "pyproject.toml").write_text("\n".join(lines))
|
|
|
|
|
|
def _make_backend_repo(tmp_path: Path) -> None:
|
|
"""Create a minimal backend-type repo skeleton in tmp_path."""
|
|
for rel in [
|
|
"src/api/v1",
|
|
"src/db/models",
|
|
"src/schemas",
|
|
"src/services",
|
|
"src/config",
|
|
"tests",
|
|
]:
|
|
(tmp_path / rel).mkdir(parents=True, exist_ok=True)
|
|
(tmp_path / "src/config/settings.py").write_text("settings = {}\n")
|
|
(tmp_path / "main.py").write_text(
|
|
"from contextlib import asynccontextmanager\n"
|
|
"@asynccontextmanager\nasync def lifespan(app): yield\n"
|
|
)
|
|
(tmp_path / "src/api/v1/users.py").write_text(
|
|
"from fastapi import APIRouter\nrouter = APIRouter()\n"
|
|
"@router.get('/users')\nasync def list_users():\n return []\n"
|
|
)
|
|
_write_pyproject(tmp_path, deps=["fastapi", "uvicorn"], cov_source=["src"], cov_fail="80")
|
|
(tmp_path / "tests/conftest.py").write_text("import pytest\n")
|
|
(tmp_path / "tests/test_users.py").write_text("def test_ok(): assert True\n")
|
|
# Extras for check_pyproject (group 8) to pass:
|
|
(tmp_path / ".python-version").write_text("3.12\n")
|
|
(tmp_path / "uv.lock").write_text("# minimal lockfile stub\n")
|
|
(tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n")
|
|
|
|
|
|
# ── parse_remote_url ─────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_parse_remote_url_https():
|
|
assert ps.parse_remote_url("https://github.com/slaid098/opencode-config.git") == (
|
|
"github.com",
|
|
"slaid098",
|
|
"opencode-config",
|
|
)
|
|
|
|
|
|
def test_parse_remote_url_ssh():
|
|
assert ps.parse_remote_url("git@github.com:slaid098/opencode-config.git") == (
|
|
"github.com",
|
|
"slaid098",
|
|
"opencode-config",
|
|
)
|
|
|
|
|
|
def test_parse_remote_url_invalid():
|
|
with pytest.raises(ValueError, match="Cannot parse remote URL"):
|
|
ps.parse_remote_url("not-a-valid-url")
|
|
|
|
|
|
# ── get_repo_full_name ───────────────────────────────────────────────────────
|
|
|
|
|
|
def test_get_repo_full_name_ok(monkeypatch):
|
|
monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({}))
|
|
assert ps.get_repo_full_name() == "slaid098/opencode-config"
|
|
|
|
|
|
def test_get_repo_full_name_no_remote(monkeypatch):
|
|
monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("git", "remote"): (1, "", "no remote")}))
|
|
assert ps.get_repo_full_name() is None
|
|
|
|
|
|
def test_get_repo_full_name_bad_url(monkeypatch):
|
|
monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("git", "remote"): (0, "not-a-url\n", "")}))
|
|
assert ps.get_repo_full_name() is None
|
|
|
|
|
|
# ── load_config ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_load_config_defaults_when_no_pyproject(tmp_path):
|
|
monkeypatch = pytest.MonkeyPatch()
|
|
monkeypatch.setattr(ps, "REPO_ROOT", tmp_path)
|
|
cfg = ps.load_config()
|
|
assert cfg["route_line_limit"] == 50
|
|
assert cfg["min_test_count"] == 1
|
|
|
|
|
|
def test_load_config_reads_section(tmp_path):
|
|
monkeypatch = pytest.MonkeyPatch()
|
|
monkeypatch.setattr(ps, "REPO_ROOT", tmp_path)
|
|
(tmp_path / "pyproject.toml").write_text(
|
|
"[tool.project-status]\nroute_line_limit = 80\nmin_test_count = 3\n"
|
|
)
|
|
cfg = ps.load_config()
|
|
assert cfg["route_line_limit"] == 80
|
|
assert cfg["min_test_count"] == 3
|
|
assert cfg["require_branch_protection"] is False
|
|
|
|
|
|
def test_load_config_bad_toml_returns_defaults(tmp_path):
|
|
monkeypatch = pytest.MonkeyPatch()
|
|
monkeypatch.setattr(ps, "REPO_ROOT", tmp_path)
|
|
(tmp_path / "pyproject.toml").write_text("not valid toml = = =")
|
|
cfg = ps.load_config()
|
|
assert cfg["route_line_limit"] == 50
|
|
|
|
|
|
# ── detect_project_type ──────────────────────────────────────────────────────
|
|
|
|
|
|
def test_detect_fullstack(tmp_path):
|
|
(tmp_path / "backend").mkdir()
|
|
(tmp_path / "frontend").mkdir()
|
|
assert ps.detect_project_type() == ps.ProjectType.FULLSTACK
|
|
|
|
|
|
def test_detect_backend(tmp_path):
|
|
_make_backend_repo(tmp_path)
|
|
assert ps.detect_project_type() == ps.ProjectType.BACKEND
|
|
|
|
|
|
def test_detect_bot_via_file(tmp_path):
|
|
(tmp_path / "src").mkdir()
|
|
(tmp_path / "src/bot.py").write_text("from aiogram import Dispatcher\n")
|
|
_write_pyproject(tmp_path, deps=[])
|
|
assert ps.detect_project_type() == ps.ProjectType.BOT
|
|
|
|
|
|
def test_detect_bot_via_dep(tmp_path):
|
|
_write_pyproject(tmp_path, deps=["aiogram"])
|
|
assert ps.detect_project_type() == ps.ProjectType.BOT
|
|
|
|
|
|
def test_detect_worker_via_file(tmp_path):
|
|
(tmp_path / "src").mkdir()
|
|
(tmp_path / "src/flow.py").write_text("from prefect import flow\n")
|
|
_write_pyproject(tmp_path, deps=[])
|
|
assert ps.detect_project_type() == ps.ProjectType.WORKER
|
|
|
|
|
|
def test_detect_worker_via_dep(tmp_path):
|
|
_write_pyproject(tmp_path, deps=["prefect"])
|
|
assert ps.detect_project_type() == ps.ProjectType.WORKER
|
|
|
|
|
|
def test_detect_cli(tmp_path):
|
|
(tmp_path / "src").mkdir()
|
|
pkg = tmp_path / "src" / "mycli"
|
|
pkg.mkdir()
|
|
(pkg / "__init__.py").write_text("")
|
|
_write_pyproject(tmp_path, deps=["typer"], scripts={"mycli": "mycli.cli:main"})
|
|
assert ps.detect_project_type() == ps.ProjectType.CLI
|
|
|
|
|
|
def test_detect_unknown_empty_repo(tmp_path):
|
|
_write_pyproject(tmp_path, deps=[])
|
|
assert ps.detect_project_type() == ps.ProjectType.UNKNOWN
|
|
|
|
|
|
# ── check_structure ──────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_check_structure_backend_ok(tmp_path):
|
|
_make_backend_repo(tmp_path)
|
|
group = ps.check_structure(ps.ProjectType.BACKEND)
|
|
assert group.overall() == ps.CheckStatus.OK
|
|
assert all(c.status == ps.CheckStatus.OK for c in group.checks)
|
|
|
|
|
|
def test_check_structure_backend_missing_dir(tmp_path):
|
|
_write_pyproject(tmp_path, deps=["fastapi", "uvicorn"])
|
|
(tmp_path / "main.py").write_text(
|
|
"from contextlib import asynccontextmanager\n"
|
|
"@asynccontextmanager\nasync def lifespan(app): yield\n"
|
|
)
|
|
group = ps.check_structure(ps.ProjectType.BACKEND)
|
|
assert group.overall() == ps.CheckStatus.FAIL
|
|
assert any(c.name == "src/api/v1" and c.status == ps.CheckStatus.FAIL for c in group.checks)
|
|
|
|
|
|
def test_check_structure_backend_no_lifespan(tmp_path):
|
|
for rel in [
|
|
"src/api/v1",
|
|
"src/db/models",
|
|
"src/schemas",
|
|
"src/services",
|
|
"src/config",
|
|
"tests",
|
|
]:
|
|
(tmp_path / rel).mkdir(parents=True, exist_ok=True)
|
|
(tmp_path / "src/config/settings.py").write_text("settings = {}\n")
|
|
(tmp_path / "main.py").write_text("app = None\n")
|
|
group = ps.check_structure(ps.ProjectType.BACKEND)
|
|
assert any(
|
|
c.name == "main.py lifespan" and c.status == ps.CheckStatus.WARN for c in group.checks
|
|
)
|
|
|
|
|
|
def test_check_structure_unknown_warn(tmp_path):
|
|
group = ps.check_structure(ps.ProjectType.UNKNOWN)
|
|
assert group.overall() == ps.CheckStatus.WARN
|
|
|
|
|
|
def test_check_structure_cli_with_package(tmp_path):
|
|
(tmp_path / "src").mkdir()
|
|
pkg = tmp_path / "src" / "mycli"
|
|
pkg.mkdir()
|
|
(pkg / "__init__.py").write_text("")
|
|
group = ps.check_structure(ps.ProjectType.CLI)
|
|
assert any(c.status == ps.CheckStatus.OK and "package" in c.name for c in group.checks)
|
|
|
|
|
|
def test_check_structure_cli_no_package(tmp_path):
|
|
(tmp_path / "src").mkdir()
|
|
group = ps.check_structure(ps.ProjectType.CLI)
|
|
assert any(c.status == ps.CheckStatus.FAIL for c in group.checks)
|
|
|
|
|
|
# ── check_thin_routes ────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_thin_routes_ok(tmp_path):
|
|
_make_backend_repo(tmp_path)
|
|
group = ps.check_thin_routes(ps.ProjectType.BACKEND)
|
|
assert group.overall() == ps.CheckStatus.OK
|
|
|
|
|
|
def test_thin_routes_over_limit(tmp_path):
|
|
for rel in ["src/api/v1", "src/db/models", "src/schemas", "src/services", "src/config"]:
|
|
(tmp_path / rel).mkdir(parents=True, exist_ok=True)
|
|
(tmp_path / "src/config/settings.py").write_text("settings = {}\n")
|
|
(tmp_path / "main.py").write_text("app = None\n")
|
|
body = "\n x = 1\n" * 60
|
|
(tmp_path / "src/api/v1/users.py").write_text(
|
|
"from fastapi import APIRouter\nrouter = APIRouter()\n"
|
|
"@router.get('/users')\nasync def list_users():"
|
|
f"{body} return []\n"
|
|
)
|
|
group = ps.check_thin_routes(ps.ProjectType.BACKEND)
|
|
assert group.overall() == ps.CheckStatus.FAIL
|
|
assert any("превышение" in c.detail for c in group.checks)
|
|
|
|
|
|
def test_thin_routes_not_applicable_for_cli(tmp_path):
|
|
group = ps.check_thin_routes(ps.ProjectType.CLI)
|
|
assert group.overall() == ps.CheckStatus.OK
|
|
|
|
|
|
def test_thin_routes_no_api_dir(tmp_path):
|
|
group = ps.check_thin_routes(ps.ProjectType.BACKEND)
|
|
assert group.overall() == ps.CheckStatus.WARN
|
|
|
|
|
|
# ── check_quality ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_quality_ok(tmp_path):
|
|
_make_backend_repo(tmp_path)
|
|
group = ps.check_quality(ps.ProjectType.BACKEND)
|
|
assert group.overall() == ps.CheckStatus.OK
|
|
|
|
|
|
def test_quality_missing_mypy(tmp_path):
|
|
_write_pyproject(tmp_path, deps=["fastapi"], has_mypy=False)
|
|
group = ps.check_quality(ps.ProjectType.BACKEND)
|
|
assert any(c.name == "mypy" and c.status == ps.CheckStatus.FAIL for c in group.checks)
|
|
|
|
|
|
def test_quality_missing_ruff(tmp_path):
|
|
_write_pyproject(tmp_path, deps=["fastapi"], has_ruff=False)
|
|
group = ps.check_quality(ps.ProjectType.BACKEND)
|
|
assert any(c.name == "ruff" and c.status == ps.CheckStatus.FAIL for c in group.checks)
|
|
|
|
|
|
# ── check_tests ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_tests_ok(tmp_path):
|
|
_make_backend_repo(tmp_path)
|
|
group = ps.check_tests(ps.ProjectType.BACKEND)
|
|
assert group.overall() == ps.CheckStatus.OK
|
|
|
|
|
|
def test_tests_no_dir(tmp_path):
|
|
group = ps.check_tests(ps.ProjectType.BACKEND)
|
|
assert group.overall() == ps.CheckStatus.FAIL
|
|
assert any("tests/" in c.name for c in group.checks)
|
|
|
|
|
|
def test_tests_no_conftest(tmp_path):
|
|
(tmp_path / "tests").mkdir()
|
|
(tmp_path / "tests/test_x.py").write_text("def test_x(): assert True\n")
|
|
group = ps.check_tests(ps.ProjectType.BACKEND)
|
|
assert any(c.name == "conftest.py" and c.status == ps.CheckStatus.WARN for c in group.checks)
|
|
|
|
|
|
def test_tests_asyncio_mark_warns(tmp_path):
|
|
(tmp_path / "tests").mkdir()
|
|
(tmp_path / "tests/test_x.py").write_text(
|
|
"import pytest\n@pytest.mark.asyncio\nasync def test_x(): assert True\n"
|
|
)
|
|
group = ps.check_tests(ps.ProjectType.BACKEND)
|
|
assert any(
|
|
c.name == "no @pytest.mark.asyncio" and c.status == ps.CheckStatus.WARN
|
|
for c in group.checks
|
|
)
|
|
|
|
|
|
# ── check_readme ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
def _write_valid_readme(tmp_path: Path) -> None:
|
|
content = (
|
|
"# 🚀 Title\n[English](#-english) [Русский](#-русский)\n"
|
|
"## 🇺🇸 English\n## 🇷🇺 Русский\nassets/cover.png\n"
|
|
)
|
|
for d in ps.README_DELIMITERS:
|
|
content += f"<!-- {d} -->\n"
|
|
(tmp_path / "README.md").write_text(content)
|
|
|
|
|
|
def test_readme_ok(tmp_path):
|
|
_write_valid_readme(tmp_path)
|
|
group = ps.check_readme(ps.ProjectType.BACKEND)
|
|
assert group.overall() == ps.CheckStatus.OK
|
|
|
|
|
|
def test_readme_missing(tmp_path):
|
|
group = ps.check_readme(ps.ProjectType.BACKEND)
|
|
assert group.overall() == ps.CheckStatus.FAIL
|
|
|
|
|
|
def test_readme_missing_delimiters(tmp_path):
|
|
(tmp_path / "README.md").write_text(
|
|
"# 🚀 Title\n## 🇺🇸 English\n## 🇷🇺 Русский\n[English](#-english)\n"
|
|
)
|
|
group = ps.check_readme(ps.ProjectType.BACKEND)
|
|
assert any("delimiter" in c.name and c.status == ps.CheckStatus.FAIL for c in group.checks)
|
|
|
|
|
|
# ── check_infra ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_infra_ok_with_branch_protection(monkeypatch, tmp_path):
|
|
for rel in [".github/workflows", ".github"]:
|
|
(tmp_path / rel).mkdir(parents=True, exist_ok=True)
|
|
(tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n")
|
|
(tmp_path / ".github/dependabot.yml").write_text("version: 2\n")
|
|
(tmp_path / "LICENSE").write_text("MIT\n")
|
|
(tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n")
|
|
monkeypatch.setattr(
|
|
ps,
|
|
"run_cmd",
|
|
mock_run_cmd(
|
|
{
|
|
("gh", "api"): (
|
|
0,
|
|
'{"rules":[{"type":"pull_request"},{"type":"required_status_checks"}]}',
|
|
"",
|
|
),
|
|
}
|
|
),
|
|
)
|
|
group = ps.check_infra(ps.ProjectType.BACKEND, fast=False)
|
|
assert group.overall() == ps.CheckStatus.OK
|
|
|
|
|
|
def test_infra_missing_ci_fail(tmp_path):
|
|
(tmp_path / "LICENSE").write_text("MIT\n")
|
|
group = ps.check_infra(ps.ProjectType.BACKEND, fast=True)
|
|
assert any(
|
|
c.name == ".github/workflows/ci.yml" and c.status == ps.CheckStatus.FAIL
|
|
for c in group.checks
|
|
)
|
|
|
|
|
|
def test_infra_fast_skips_branch_protection(tmp_path):
|
|
(tmp_path / ".github/workflows").mkdir(parents=True, exist_ok=True)
|
|
(tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n")
|
|
(tmp_path / "LICENSE").write_text("MIT\n")
|
|
group = ps.check_infra(ps.ProjectType.BACKEND, fast=True)
|
|
assert any(
|
|
c.name == "branch protection" and c.status == ps.CheckStatus.WARN and "--fast" in c.detail
|
|
for c in group.checks
|
|
)
|
|
|
|
|
|
def test_infra_branch_protection_gh_error(monkeypatch, tmp_path):
|
|
(tmp_path / ".github/workflows").mkdir(parents=True, exist_ok=True)
|
|
(tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n")
|
|
(tmp_path / "LICENSE").write_text("MIT\n")
|
|
monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "not found")}))
|
|
group = ps.check_infra(ps.ProjectType.BACKEND, fast=False)
|
|
assert any(
|
|
c.name == "branch protection" and c.status == ps.CheckStatus.WARN for c in group.checks
|
|
)
|
|
|
|
|
|
# ── check_coverage ───────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_coverage_non_blocking(tmp_path):
|
|
_write_pyproject(tmp_path, deps=["fastapi"], cov_source=["src"], cov_fail="80")
|
|
group = ps.check_coverage(ps.ProjectType.BACKEND)
|
|
assert group.overall() == ps.CheckStatus.OK
|
|
assert all(c.status != ps.CheckStatus.FAIL for c in group.checks)
|
|
|
|
|
|
def test_coverage_no_config(tmp_path):
|
|
_write_pyproject(tmp_path, deps=["fastapi"], has_pytest=False)
|
|
group = ps.check_coverage(ps.ProjectType.BACKEND)
|
|
assert group.overall() == ps.CheckStatus.WARN
|
|
assert all(c.status != ps.CheckStatus.FAIL for c in group.checks)
|
|
|
|
|
|
# ── format_output ────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_format_output_has_summary_and_recommendations():
|
|
ptype = ps.ProjectType.BACKEND
|
|
groups = [
|
|
ps.GroupResult(
|
|
name="Структура",
|
|
checks=[
|
|
ps.CheckResult(ps.CheckStatus.OK, "src/api/v1", "ok"),
|
|
ps.CheckResult(ps.CheckStatus.FAIL, "main.py", "missing"),
|
|
],
|
|
),
|
|
ps.GroupResult(name="Coverage", checks=[ps.CheckResult(ps.CheckStatus.OK, "cov", "ok")]),
|
|
]
|
|
out = ps.format_output(ptype, groups)
|
|
assert "Project: backend" in out
|
|
assert "[OK] Структура" not in out
|
|
assert "[FAIL] Структура" in out
|
|
assert "Итог:" in out
|
|
assert "Рекомендации:" in out
|
|
assert "main.py" in out
|
|
|
|
|
|
def test_format_output_no_recommendations_when_all_ok():
|
|
groups = [
|
|
ps.GroupResult(name="Структура", checks=[ps.CheckResult(ps.CheckStatus.OK, "x", "ok")]),
|
|
]
|
|
out = ps.format_output(ps.ProjectType.BACKEND, groups)
|
|
assert "Рекомендации:" not in out
|
|
assert "FAIL: 0" in out
|
|
|
|
|
|
# ── main / exit codes ────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_main_non_blocking_exit_0(monkeypatch, tmp_path, capsys):
|
|
_make_backend_repo(tmp_path)
|
|
monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "no auth")}))
|
|
monkeypatch.setattr("sys.argv", ["project-status.py"])
|
|
with pytest.raises(SystemExit) as exc:
|
|
ps.main()
|
|
assert exc.value.code == 0
|
|
captured = capsys.readouterr()
|
|
assert "Project:" in captured.out
|
|
assert "Итог:" in captured.out
|
|
|
|
|
|
def test_main_check_strict_exit_1_on_fail(monkeypatch, tmp_path, capsys):
|
|
monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "no auth")}))
|
|
monkeypatch.setattr("sys.argv", ["project-status.py", "--check"])
|
|
with pytest.raises(SystemExit) as exc:
|
|
ps.main()
|
|
assert exc.value.code == 1
|
|
|
|
|
|
def test_main_check_strict_exit_0_when_all_ok(monkeypatch, tmp_path, capsys):
|
|
_make_backend_repo(tmp_path)
|
|
for rel in [".github/workflows", ".github"]:
|
|
(tmp_path / rel).mkdir(parents=True, exist_ok=True)
|
|
(tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n")
|
|
(tmp_path / ".github/dependabot.yml").write_text("version: 2\n")
|
|
(tmp_path / "LICENSE").write_text("MIT\n")
|
|
(tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n")
|
|
_write_valid_readme(tmp_path)
|
|
monkeypatch.setattr(
|
|
ps,
|
|
"run_cmd",
|
|
mock_run_cmd(
|
|
{
|
|
("gh", "api"): (
|
|
0,
|
|
'{"rules":[{"type":"pull_request"},{"type":"required_status_checks"}]}',
|
|
"",
|
|
),
|
|
}
|
|
),
|
|
)
|
|
monkeypatch.setattr("sys.argv", ["project-status.py", "--check"])
|
|
with pytest.raises(SystemExit) as exc:
|
|
ps.main()
|
|
captured = capsys.readouterr()
|
|
assert exc.value.code == 0, f"expected exit 0, got {exc.value.code}\n{captured.out}"
|
|
|
|
|
|
def test_main_fast_flag(monkeypatch, tmp_path, capsys):
|
|
_make_backend_repo(tmp_path)
|
|
monkeypatch.setattr(
|
|
ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "should not be called")})
|
|
)
|
|
monkeypatch.setattr("sys.argv", ["project-status.py", "--fast"])
|
|
with pytest.raises(SystemExit) as exc:
|
|
ps.main()
|
|
assert exc.value.code == 0
|
|
captured = capsys.readouterr()
|
|
assert "--fast" in captured.out
|
|
|
|
|
|
# ── run_all_checks integration ───────────────────────────────────────────────
|
|
|
|
|
|
def test_run_all_checks_returns_8_groups(tmp_path):
|
|
_make_backend_repo(tmp_path)
|
|
groups = ps.run_all_checks(ps.ProjectType.BACKEND, fast=True)
|
|
assert len(groups) == 8
|
|
assert [g.name for g in groups] == ps.CHECK_GROUPS
|
|
|
|
|
|
# ── check_pyproject (group 8, 13 checks) ─────────────────────────────────────
|
|
|
|
|
|
def _write_full_pyproject( # noqa: C901, PLR0912, PLR0915
|
|
tmp_path: Path,
|
|
*,
|
|
name: str = "test-repo",
|
|
description: str = "test repo",
|
|
requires_python: str = ">=3.12",
|
|
has_build_system: bool = True,
|
|
has_project_fields: bool = True,
|
|
has_ruff: bool = True,
|
|
has_mypy: bool = True,
|
|
mypy_strict: bool = True,
|
|
has_pytest: bool = True,
|
|
pytest_asyncio_auto: bool = True,
|
|
pytest_testpaths_tests: bool = True,
|
|
has_cov_run: bool = True,
|
|
cov_branch: bool = True,
|
|
has_cov_report_exclude: bool = True,
|
|
has_cov_fail_under: bool = True,
|
|
cov_fail: str = "80",
|
|
has_project_status_section: bool = False,
|
|
has_pre_commit: bool = True,
|
|
has_uv_lock: bool = True,
|
|
has_python_version: bool = True,
|
|
python_version_content: str = "3.12\n",
|
|
ruff_toml: bool = False,
|
|
mypy_ini: bool = False,
|
|
src_pkg_exists: bool = False,
|
|
hatch_packages_override: list | None = None,
|
|
) -> None:
|
|
"""Write a pyproject.toml with fine-grained control over every check section.
|
|
|
|
Used by the 13-check test suite for ``check_pyproject``. Each kwarg
|
|
toggles a specific check's pass/warn/fail condition.
|
|
"""
|
|
norm_name = ps._normalize_package_name(name)
|
|
lines: list[str] = []
|
|
if has_build_system:
|
|
lines.extend(
|
|
[
|
|
"[build-system]",
|
|
'requires = ["hatchling"]',
|
|
'build-backend = "hatchling.build"',
|
|
"",
|
|
]
|
|
)
|
|
project_lines = ["[project]", f'name = "{name}"', 'version = "0.1.0"']
|
|
if has_project_fields:
|
|
project_lines.append(f'description = "{description}"')
|
|
project_lines.append(f'requires-python = "{requires_python}"')
|
|
lines.extend([*project_lines, "", "dependencies = []", ""])
|
|
if src_pkg_exists:
|
|
(tmp_path / "src" / norm_name).mkdir(parents=True, exist_ok=True)
|
|
(tmp_path / "src" / norm_name / "__init__.py").write_text("")
|
|
lines.append("[tool.hatch.build.targets.wheel]")
|
|
lines.append(f'packages = ["src/{norm_name}"]')
|
|
lines.append("")
|
|
elif hatch_packages_override is not None:
|
|
lines.append("[tool.hatch.build.targets.wheel]")
|
|
pkgs_str = ", ".join(f'"{p}"' for p in hatch_packages_override)
|
|
lines.append(f"packages = [{pkgs_str}]")
|
|
lines.append("")
|
|
if has_ruff and not ruff_toml:
|
|
lines.extend(["[tool.ruff]", 'target-version = "py312"', "line-length = 100", ""])
|
|
if ruff_toml:
|
|
(tmp_path / "ruff.toml").write_text("line-length = 100\ntarget-version = py312\n")
|
|
if has_mypy and not mypy_ini:
|
|
lines.extend(["[tool.mypy]"])
|
|
if mypy_strict:
|
|
lines.append("strict = true")
|
|
else:
|
|
lines.append('python_version = "3.12"')
|
|
lines.append("")
|
|
if mypy_ini:
|
|
(tmp_path / "mypy.ini").write_text("[mypy]\nstrict = True\n")
|
|
if has_pytest:
|
|
lines.append("[tool.pytest.ini_options]")
|
|
if pytest_asyncio_auto:
|
|
lines.append('asyncio_mode = "auto"')
|
|
if pytest_testpaths_tests:
|
|
lines.append('testpaths = ["tests"]')
|
|
addopts = "--cov=src --cov-report=term-missing"
|
|
if has_cov_fail_under and cov_fail:
|
|
addopts += f" --cov-fail-under={cov_fail}"
|
|
lines.append(f'addopts = "{addopts}"')
|
|
lines.append("")
|
|
if has_cov_run:
|
|
lines.append("[tool.coverage.run]")
|
|
lines.append('source = ["src"]')
|
|
if cov_branch:
|
|
lines.append("branch = true")
|
|
lines.append("")
|
|
if has_cov_report_exclude:
|
|
lines.append("[tool.coverage.report]")
|
|
lines.append("exclude_lines = [")
|
|
lines.append(' "pragma: no cover",')
|
|
lines.append(' "if __name__ == .__main__.:",')
|
|
lines.append(' "if TYPE_CHECKING:",')
|
|
lines.append("]")
|
|
lines.append("")
|
|
if has_project_status_section:
|
|
lines.extend(
|
|
[
|
|
"[tool.project-status]",
|
|
"thin_routes_max_lines = 50",
|
|
"cov_fail_under = 80",
|
|
'required_dirs_backend = ["src/api/v1"]',
|
|
"",
|
|
]
|
|
)
|
|
(tmp_path / "pyproject.toml").write_text("\n".join(lines))
|
|
if has_pre_commit:
|
|
(tmp_path / ".pre-commit-config.yaml").write_text("repos: []\n")
|
|
if has_uv_lock:
|
|
(tmp_path / "uv.lock").write_text("# lockfile\n")
|
|
if has_python_version:
|
|
(tmp_path / ".python-version").write_text(python_version_content)
|
|
|
|
|
|
def test_check_pyproject_all_ok(tmp_path):
|
|
_write_full_pyproject(tmp_path, has_project_status_section=True, src_pkg_exists=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert group.overall() == ps.CheckStatus.OK, (
|
|
f"expected OK, got {group.overall()}: "
|
|
+ ", ".join(f"{c.name}={c.status.value}" for c in group.checks)
|
|
)
|
|
assert len(group.checks) == 13, f"expected 13 checks, got {len(group.checks)}"
|
|
|
|
|
|
def test_check_pyproject_no_pyproject_warn(tmp_path):
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert group.overall() == ps.CheckStatus.WARN
|
|
assert any(c.name == "pyproject.toml" and c.status == ps.CheckStatus.WARN for c in group.checks)
|
|
assert len(group.checks) == 1
|
|
|
|
|
|
def test_check_pyproject_invalid_toml_fail(tmp_path):
|
|
(tmp_path / "pyproject.toml").write_text("not valid = = =")
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert group.overall() == ps.CheckStatus.FAIL
|
|
assert any("парсинг" in c.detail for c in group.checks)
|
|
|
|
|
|
def test_check_pyproject_check1_build_system_fail(tmp_path):
|
|
_write_full_pyproject(tmp_path, has_build_system=False, src_pkg_exists=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(c.name == "[build-system]" and c.status == ps.CheckStatus.FAIL for c in group.checks)
|
|
|
|
|
|
def test_check_pyproject_check2_hatch_wheel_ok_with_src_pkg(tmp_path):
|
|
_write_full_pyproject(tmp_path, src_pkg_exists=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(
|
|
c.name == "[tool.hatch.build.targets.wheel]" and c.status == ps.CheckStatus.OK
|
|
for c in group.checks
|
|
)
|
|
|
|
|
|
def test_check_pyproject_check2_hatch_wheel_fail_missing_packages(tmp_path):
|
|
"""src/<pkg>/ exists but packages doesn't reference it → FAIL."""
|
|
(tmp_path / "src").mkdir()
|
|
(tmp_path / "src" / "test_repo").mkdir()
|
|
(tmp_path / "src" / "test_repo" / "__init__.py").write_text("")
|
|
_write_full_pyproject(tmp_path, src_pkg_exists=False)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(
|
|
c.name == "[tool.hatch.build.targets.wheel]" and c.status == ps.CheckStatus.FAIL
|
|
for c in group.checks
|
|
)
|
|
|
|
|
|
def test_check_pyproject_check2_hatch_wheel_ok_with_src_root(tmp_path):
|
|
"""packages=["src"] is accepted when src/<pkg>/ exists (hatchling convention)."""
|
|
(tmp_path / "src").mkdir()
|
|
(tmp_path / "src" / "test_repo").mkdir()
|
|
(tmp_path / "src" / "test_repo" / "__init__.py").write_text("")
|
|
_write_full_pyproject(tmp_path, src_pkg_exists=False, hatch_packages_override=["src"])
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(
|
|
c.name == "[tool.hatch.build.targets.wheel]" and c.status == ps.CheckStatus.OK
|
|
for c in group.checks
|
|
)
|
|
|
|
|
|
def test_check_pyproject_check2_hatch_wheel_warn_app_layout(tmp_path):
|
|
"""src/ exists but no src/<pkg>/ → WARN (app-layout for backend)."""
|
|
(tmp_path / "src").mkdir()
|
|
(tmp_path / "src" / "api").mkdir()
|
|
_write_full_pyproject(tmp_path, src_pkg_exists=False)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(
|
|
c.name == "[tool.hatch.build.targets.wheel]"
|
|
and c.status == ps.CheckStatus.WARN
|
|
and "app-layout" in c.detail
|
|
for c in group.checks
|
|
)
|
|
|
|
|
|
def test_check_pyproject_check3_project_fields_fail(tmp_path):
|
|
_write_full_pyproject(tmp_path, has_project_fields=False, src_pkg_exists=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(c.name == "[project]" and c.status == ps.CheckStatus.FAIL for c in group.checks)
|
|
|
|
|
|
def test_check_pyproject_check4_ruff_ok(tmp_path):
|
|
_write_full_pyproject(tmp_path, src_pkg_exists=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(c.name == "[tool.ruff]" and c.status == ps.CheckStatus.OK for c in group.checks)
|
|
|
|
|
|
def test_check_pyproject_check4_ruff_toml_ok(tmp_path):
|
|
"""ruff.toml is an accepted alternative to [tool.ruff]."""
|
|
_write_full_pyproject(tmp_path, has_ruff=False, ruff_toml=True, src_pkg_exists=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(c.name == "[tool.ruff]" and c.status == ps.CheckStatus.OK for c in group.checks)
|
|
|
|
|
|
def test_check_pyproject_check4_ruff_missing_fail(tmp_path):
|
|
_write_full_pyproject(tmp_path, has_ruff=False, src_pkg_exists=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(c.name == "[tool.ruff]" and c.status == ps.CheckStatus.FAIL for c in group.checks)
|
|
|
|
|
|
def test_check_pyproject_check5_mypy_strict_ok(tmp_path):
|
|
_write_full_pyproject(tmp_path, src_pkg_exists=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(c.name == "[tool.mypy]" and c.status == ps.CheckStatus.OK for c in group.checks)
|
|
|
|
|
|
def test_check_pyproject_check5_mypy_ini_ok(tmp_path):
|
|
"""mypy.ini is an accepted alternative to [tool.mypy]."""
|
|
_write_full_pyproject(tmp_path, has_mypy=False, mypy_ini=True, src_pkg_exists=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(c.name == "[tool.mypy]" and c.status == ps.CheckStatus.OK for c in group.checks)
|
|
|
|
|
|
def test_check_pyproject_check5_mypy_not_strict_warn(tmp_path):
|
|
_write_full_pyproject(tmp_path, mypy_strict=False, src_pkg_exists=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(c.name == "[tool.mypy]" and c.status == ps.CheckStatus.WARN for c in group.checks)
|
|
|
|
|
|
def test_check_pyproject_check5_mypy_missing_fail(tmp_path):
|
|
_write_full_pyproject(tmp_path, has_mypy=False, src_pkg_exists=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(c.name == "[tool.mypy]" and c.status == ps.CheckStatus.FAIL for c in group.checks)
|
|
|
|
|
|
def test_check_pyproject_check6_pytest_ok(tmp_path):
|
|
_write_full_pyproject(tmp_path, src_pkg_exists=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(
|
|
c.name == "[tool.pytest.ini_options]" and c.status == ps.CheckStatus.OK
|
|
for c in group.checks
|
|
)
|
|
|
|
|
|
def test_check_pyproject_check6_pytest_missing_fail(tmp_path):
|
|
_write_full_pyproject(tmp_path, has_pytest=False, src_pkg_exists=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(
|
|
c.name == "[tool.pytest.ini_options]" and c.status == ps.CheckStatus.FAIL
|
|
for c in group.checks
|
|
)
|
|
|
|
|
|
def test_check_pyproject_check6_pytest_wrong_mode_warn(tmp_path):
|
|
_write_full_pyproject(
|
|
tmp_path, pytest_asyncio_auto=False, pytest_testpaths_tests=False, src_pkg_exists=True
|
|
)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(
|
|
c.name == "[tool.pytest.ini_options]" and c.status == ps.CheckStatus.WARN
|
|
for c in group.checks
|
|
)
|
|
|
|
|
|
def test_check_pyproject_check7_cov_run_ok(tmp_path):
|
|
_write_full_pyproject(tmp_path, src_pkg_exists=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(
|
|
c.name == "[tool.coverage.run]" and c.status == ps.CheckStatus.OK for c in group.checks
|
|
)
|
|
|
|
|
|
def test_check_pyproject_check7_cov_run_warn_no_branch(tmp_path):
|
|
_write_full_pyproject(tmp_path, cov_branch=False, src_pkg_exists=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(
|
|
c.name == "[tool.coverage.run]" and c.status == ps.CheckStatus.WARN for c in group.checks
|
|
)
|
|
|
|
|
|
def test_check_pyproject_check8_cov_report_exclude_ok(tmp_path):
|
|
_write_full_pyproject(tmp_path, src_pkg_exists=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(
|
|
c.name == "[tool.coverage.report]" and c.status == ps.CheckStatus.OK for c in group.checks
|
|
)
|
|
|
|
|
|
def test_check_pyproject_check8_cov_report_exclude_warn(tmp_path):
|
|
_write_full_pyproject(tmp_path, has_cov_report_exclude=False, src_pkg_exists=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(
|
|
c.name == "[tool.coverage.report]" and c.status == ps.CheckStatus.WARN for c in group.checks
|
|
)
|
|
|
|
|
|
def test_check_pyproject_check9_cov_fail_under_ok(tmp_path):
|
|
_write_full_pyproject(tmp_path, src_pkg_exists=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(
|
|
c.name == "addopts --cov-fail-under" and c.status == ps.CheckStatus.OK for c in group.checks
|
|
)
|
|
|
|
|
|
def test_check_pyproject_check9_cov_fail_under_warn(tmp_path):
|
|
_write_full_pyproject(tmp_path, has_cov_fail_under=False, src_pkg_exists=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(
|
|
c.name == "addopts --cov-fail-under" and c.status == ps.CheckStatus.WARN
|
|
for c in group.checks
|
|
)
|
|
|
|
|
|
def test_check_pyproject_check10_project_status_section_ok(tmp_path):
|
|
_write_full_pyproject(tmp_path, has_project_status_section=True, src_pkg_exists=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(
|
|
c.name == "[tool.project-status]" and c.status == ps.CheckStatus.OK for c in group.checks
|
|
)
|
|
|
|
|
|
def test_check_pyproject_check10_project_status_section_warn_default(tmp_path):
|
|
_write_full_pyproject(tmp_path, has_project_status_section=False, src_pkg_exists=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(
|
|
c.name == "[tool.project-status]" and c.status == ps.CheckStatus.WARN for c in group.checks
|
|
)
|
|
|
|
|
|
def test_check_pyproject_check11_pre_commit_ok(tmp_path):
|
|
_write_full_pyproject(tmp_path, src_pkg_exists=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(
|
|
c.name == ".pre-commit-config.yaml" and c.status == ps.CheckStatus.OK for c in group.checks
|
|
)
|
|
|
|
|
|
def test_check_pyproject_check11_pre_commit_warn(tmp_path):
|
|
_write_full_pyproject(tmp_path, has_pre_commit=False, src_pkg_exists=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(
|
|
c.name == ".pre-commit-config.yaml" and c.status == ps.CheckStatus.WARN
|
|
for c in group.checks
|
|
)
|
|
|
|
|
|
def test_check_pyproject_check12_uv_lock_ok(tmp_path):
|
|
_write_full_pyproject(tmp_path, src_pkg_exists=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(c.name == "uv.lock" and c.status == ps.CheckStatus.OK for c in group.checks)
|
|
|
|
|
|
def test_check_pyproject_check12_uv_lock_warn(tmp_path):
|
|
_write_full_pyproject(tmp_path, has_uv_lock=False, src_pkg_exists=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(c.name == "uv.lock" and c.status == ps.CheckStatus.WARN for c in group.checks)
|
|
|
|
|
|
def test_check_pyproject_check13_python_version_ok(tmp_path):
|
|
_write_full_pyproject(
|
|
tmp_path, requires_python=">=3.11", python_version_content="3.13\n", src_pkg_exists=True
|
|
)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(
|
|
c.name == "requires-python vs .python-version" and c.status == ps.CheckStatus.OK
|
|
for c in group.checks
|
|
)
|
|
|
|
|
|
def test_check_pyproject_check13_python_version_fail_incompat(tmp_path):
|
|
_write_full_pyproject(
|
|
tmp_path, requires_python=">=3.11", python_version_content="3.10\n", src_pkg_exists=True
|
|
)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(
|
|
c.name == "requires-python vs .python-version" and c.status == ps.CheckStatus.FAIL
|
|
for c in group.checks
|
|
)
|
|
|
|
|
|
def test_check_pyproject_check13_skip_when_no_python_version(tmp_path):
|
|
_write_full_pyproject(tmp_path, has_python_version=False, src_pkg_exists=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert any(
|
|
c.name == "requires-python vs .python-version"
|
|
and c.status == ps.CheckStatus.WARN
|
|
and "skip" in c.detail
|
|
for c in group.checks
|
|
)
|
|
|
|
|
|
# ── normalize_package_name ───────────────────────────────────────────────────
|
|
|
|
|
|
def test_normalize_package_name():
|
|
assert ps._normalize_package_name("my-project") == "my_project"
|
|
assert ps._normalize_package_name("My.Project") == "my_project"
|
|
assert ps._normalize_package_name("my_project") == "my_project"
|
|
assert ps._normalize_package_name("MY-PROJECT") == "my_project"
|
|
|
|
|
|
# ── --repo flag ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
def test_main_repo_flag_nonexistent_path_exits_1(monkeypatch, tmp_path, capsys):
|
|
"""--repo with non-existent path → exit 1 + error message."""
|
|
monkeypatch.setattr("sys.argv", ["project-status.py", "--repo", str(tmp_path / "nonexistent")])
|
|
with pytest.raises(SystemExit) as exc:
|
|
ps.main()
|
|
assert exc.value.code == 1
|
|
captured = capsys.readouterr()
|
|
assert "repo path not found" in captured.out
|
|
|
|
|
|
def test_main_repo_flag_overrides_repo_root(monkeypatch, tmp_path, capsys):
|
|
"""--repo with valid path → REPO_ROOT overridden, checks run against it."""
|
|
_make_backend_repo(tmp_path)
|
|
for rel in [".github/workflows", ".github"]:
|
|
(tmp_path / rel).mkdir(parents=True, exist_ok=True)
|
|
(tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n")
|
|
(tmp_path / ".github/dependabot.yml").write_text("version: 2\n")
|
|
(tmp_path / "LICENSE").write_text("MIT\n")
|
|
_write_valid_readme(tmp_path)
|
|
monkeypatch.setattr(
|
|
ps,
|
|
"run_cmd",
|
|
mock_run_cmd(
|
|
{
|
|
("gh", "api"): (
|
|
0,
|
|
'{"rules":[{"type":"pull_request"},{"type":"required_status_checks"}]}',
|
|
"",
|
|
),
|
|
}
|
|
),
|
|
)
|
|
monkeypatch.setattr("sys.argv", ["project-status.py", "--repo", str(tmp_path)])
|
|
with pytest.raises(SystemExit) as exc:
|
|
ps.main()
|
|
assert exc.value.code == 0, f"expected exit 0, got {exc.value.code}"
|
|
captured = capsys.readouterr()
|
|
assert "Project:" in captured.out
|
|
assert "Итог:" in captured.out
|
|
|
|
|
|
def test_main_repo_flag_relative_path(monkeypatch, tmp_path, capsys):
|
|
"""--repo with relative path → resolved against cwd."""
|
|
_make_backend_repo(tmp_path)
|
|
for rel in [".github/workflows", ".github"]:
|
|
(tmp_path / rel).mkdir(parents=True, exist_ok=True)
|
|
(tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n")
|
|
(tmp_path / "LICENSE").write_text("MIT\n")
|
|
monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({("gh", "api"): (1, "", "no auth")}))
|
|
# cwd = tmp_path, repo = "." (relative)
|
|
monkeypatch.chdir(tmp_path)
|
|
monkeypatch.setattr("sys.argv", ["project-status.py", "--repo", "."])
|
|
with pytest.raises(SystemExit) as exc:
|
|
ps.main()
|
|
assert exc.value.code == 0
|
|
captured = capsys.readouterr()
|
|
assert "Project:" in captured.out
|
|
|
|
|
|
def test_check_pyproject_uses_repo_root(tmp_path):
|
|
"""check_pyproject reads pyproject.toml from REPO_ROOT (tmp_path fixture)."""
|
|
_write_full_pyproject(tmp_path, src_pkg_exists=True, has_project_status_section=True)
|
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
|
assert group.overall() == ps.CheckStatus.OK, (
|
|
f"expected OK, got {group.overall()}: "
|
|
+ ", ".join(
|
|
f"{c.name}={c.status.value}" for c in group.checks if c.status != ps.CheckStatus.OK
|
|
)
|
|
)
|
|
|
|
|
|
# ── flat-layout check in check_structure ──────────────────────────────────────
|
|
|
|
|
|
def test_check_structure_flat_layout_warn_for_cli(tmp_path):
|
|
"""CLI/UNKNOWN with src/ but no nested package → WARN flat src/."""
|
|
(tmp_path / "src").mkdir()
|
|
(tmp_path / "src" / "api").mkdir()
|
|
(tmp_path / "src" / "db").mkdir()
|
|
_write_pyproject(tmp_path, deps=["typer"], scripts={"mycli": "mycli.cli:main"})
|
|
group = ps.check_structure(ps.ProjectType.CLI)
|
|
assert any(
|
|
c.name == "flat src/ layout" and c.status == ps.CheckStatus.WARN for c in group.checks
|
|
)
|
|
|
|
|
|
def test_check_structure_no_flat_warn_for_backend(tmp_path):
|
|
"""Backend with src/api/ (app-layout) → no flat-layout WARN."""
|
|
_make_backend_repo(tmp_path)
|
|
group = ps.check_structure(ps.ProjectType.BACKEND)
|
|
assert not any(c.name == "flat src/ layout" for c in group.checks)
|
|
|
|
|
|
def test_check_structure_no_flat_warn_when_nested_pkg(tmp_path):
|
|
"""CLI with src/<pkg>/__init__.py → no flat-layout WARN."""
|
|
(tmp_path / "src").mkdir()
|
|
pkg = tmp_path / "src" / "mycli"
|
|
pkg.mkdir()
|
|
(pkg / "__init__.py").write_text("")
|
|
_write_pyproject(tmp_path, deps=["typer"], scripts={"mycli": "mycli.cli:main"}, name="mycli")
|
|
group = ps.check_structure(ps.ProjectType.CLI)
|
|
assert not any(c.name == "flat src/ layout" for c in group.checks)
|
|
|
|
|
|
# ── get_repo_full_name with repo arg ─────────────────────────────────────────
|
|
|
|
|
|
def test_get_repo_full_name_with_repo_arg(monkeypatch, tmp_path):
|
|
"""get_repo_full_name(repo=Path) uses git -C <repo>."""
|
|
(tmp_path / ".git").mkdir() # mark as git repo (not actually needed for mock)
|
|
|
|
# Override the default GIT_REMOTE_MOCK so only the -C form matches
|
|
def _mock(args: list[str]) -> tuple[int, str, str]:
|
|
if args[:3] == ["git", "-C", str(tmp_path)] and args[3:] == ["remote", "get-url", "origin"]:
|
|
return (0, "https://github.com/slaid098/foo.git\n", "")
|
|
return (1, "", f"unmocked: {args}")
|
|
|
|
monkeypatch.setattr(ps, "run_cmd", _mock)
|
|
assert ps.get_repo_full_name(tmp_path) == "slaid098/foo"
|
|
|
|
|
|
def test_get_repo_full_name_no_repo_arg_uses_cwd(monkeypatch):
|
|
"""get_repo_full_name() without repo arg → plain git remote (cwd)."""
|
|
monkeypatch.setattr(ps, "run_cmd", mock_run_cmd({}))
|
|
assert ps.get_repo_full_name() == "slaid098/opencode-config"
|
|
|
|
|
|
# ── check_infra with repo_root (git -C) ─────────────────────────────────────
|
|
|
|
|
|
def test_check_infra_repo_root_git_c(monkeypatch, tmp_path):
|
|
"""check_infra(repo_root=...) → git -C <repo_root> for branch protection."""
|
|
for rel in [".github/workflows", ".github"]:
|
|
(tmp_path / rel).mkdir(parents=True, exist_ok=True)
|
|
(tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n")
|
|
(tmp_path / "LICENSE").write_text("MIT\n")
|
|
|
|
def _mock(args: list[str]) -> tuple[int, str, str]:
|
|
if args[:3] == ["git", "-C", str(tmp_path)] and args[3:] == ["remote", "get-url", "origin"]:
|
|
return (0, "https://github.com/slaid098/bar.git\n", "")
|
|
if args[:2] == ["gh", "api"] and len(args) >= 3 and "repos/slaid098/bar" in args[2]:
|
|
return (0, '{"rules":[{"type":"pull_request"},{"type":"required_status_checks"}]}', "")
|
|
return (1, "", f"unmocked: {args}")
|
|
|
|
monkeypatch.setattr(ps, "run_cmd", _mock)
|
|
group = ps.check_infra(ps.ProjectType.BACKEND, fast=False, repo_root=tmp_path)
|
|
assert any(
|
|
c.name == "branch protection" and c.status == ps.CheckStatus.OK for c in group.checks
|
|
)
|
|
|
|
|
|
def test_check_infra_repo_root_not_git_repo_warn(monkeypatch, tmp_path):
|
|
"""check_infra(repo_root=non-git) → WARN 'git remote недоступен'."""
|
|
(tmp_path / ".github/workflows").mkdir(parents=True, exist_ok=True)
|
|
(tmp_path / ".github/workflows/ci.yml").write_text("name: CI\n")
|
|
(tmp_path / "LICENSE").write_text("MIT\n")
|
|
|
|
def _mock(args: list[str]) -> tuple[int, str, str]:
|
|
if args[:3] == ["git", "-C", str(tmp_path)]:
|
|
return (1, "", "not a git repo")
|
|
return (1, "", f"unmocked: {args}")
|
|
|
|
monkeypatch.setattr(ps, "run_cmd", _mock)
|
|
group = ps.check_infra(ps.ProjectType.BACKEND, fast=False, repo_root=tmp_path)
|
|
assert any(
|
|
c.name == "branch protection" and c.status == ps.CheckStatus.WARN for c in group.checks
|
|
)
|