fix(scripts): project-status fullstack backend/ path + db/models + no_db removal (#276)
* refactor(contract): drop NO_DB_SUPPORTED set (issue #274) * fix(scripts): route backend checks via backend_root + auto-detect db/models from deps * refactor(templates): drop no_db marker write from cookiecutter hooks * test(scripts): cover db/models auto-detect + fullstack all-groups + use_db=no detect --------- Co-authored-by: opencode-agent <agent@opencode.local>
This commit is contained in:
parent
fa6fc9ecbd
commit
c9198a40a7
7 changed files with 363 additions and 190 deletions
|
|
@ -16,7 +16,7 @@ Usage:
|
|||
|
||||
Project types (auto-detected):
|
||||
fullstack — ``frontend/`` dir (SvelteKit) + ``backend/`` dir
|
||||
backend — ``src/<package>/api/v1/`` + ``src/<package>/db/models/`` + fastapi
|
||||
backend — ``src/<package>/api/v1/`` + fastapi (db/models auto-detected from deps)
|
||||
cli — ``[project.scripts]`` in pyproject.toml + typer in deps
|
||||
bot — ``src/bot.py`` OR aiogram in deps
|
||||
worker — ``src/flow.py`` OR prefect in deps
|
||||
|
|
@ -75,7 +75,6 @@ DEFAULT_CONFIG: dict[str, Any] = {
|
|||
"route_line_limit": 50,
|
||||
"min_test_count": 1,
|
||||
"require_branch_protection": False,
|
||||
"no_db": False,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -84,10 +83,6 @@ def load_config(root: Path | None = None) -> dict[str, Any]:
|
|||
|
||||
Falls back to ``DEFAULT_CONFIG`` if the section or file is missing.
|
||||
Uses ``tomllib`` (stdlib, Python 3.11+). Reads only — never writes.
|
||||
|
||||
Supports ``no_db: bool`` — when true, BACKEND skips the ``db/models``
|
||||
structure check (cookiecutter ``use_db=no`` writes this marker in
|
||||
``post_gen_project.py``).
|
||||
"""
|
||||
base = root if root is not None else REPO_ROOT
|
||||
cfg: dict[str, Any] = dict(DEFAULT_CONFIG)
|
||||
|
|
@ -155,15 +150,22 @@ class GroupResult:
|
|||
|
||||
@dataclass(frozen=True)
|
||||
class RepoCtx:
|
||||
"""Immutable repo context: root path + config thresholds.
|
||||
"""Immutable repo context: root path + config thresholds + backend root.
|
||||
|
||||
Passed explicitly to all check-functions to avoid module-level globals
|
||||
(``REPO_ROOT`` / ``CONFIG``). Mirrors ``CiPollConfig`` in
|
||||
``pipeline-status.py``.
|
||||
|
||||
``backend_root`` is the directory where the backend source tree lives:
|
||||
``ctx.root / "backend"`` for FULLSTACK, ``ctx.root`` for all other types.
|
||||
Computed in ``main()`` / ``detect_project_type`` after type detection.
|
||||
Root-level files (ci.yml, README, LICENSE, dependabot, frontend/) stay
|
||||
under ``ctx.root`` regardless of type.
|
||||
"""
|
||||
|
||||
root: Path
|
||||
config: dict[str, Any]
|
||||
backend_root: Path = Path() # overridable; set by main()/detect_project_type
|
||||
|
||||
|
||||
# ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
|
@ -175,6 +177,31 @@ def run_cmd(args: list[str]) -> tuple[int, str, str]:
|
|||
return result.returncode, result.stdout, result.stderr
|
||||
|
||||
|
||||
def _backend_root_for(ptype: ProjectType, ctx: RepoCtx) -> Path:
|
||||
"""Return the backend root dir for the given project type.
|
||||
|
||||
FULLSTACK → ``ctx.root / "backend"`` (backend source tree lives there);
|
||||
all other types → ``ctx.root``. Used to route backend-source checks
|
||||
(lifespan, scattered models, pyproject, tests, ...) to the right tree
|
||||
without touching the root-level checks (ci.yml, README, frontend/).
|
||||
Mirrors the ``backend_ctx = RepoCtx(root=ctx.root / "backend")`` pattern
|
||||
in ``_api_dirs_for`` (issue #241 / #274).
|
||||
"""
|
||||
if ptype == ProjectType.FULLSTACK:
|
||||
return ctx.root / "backend"
|
||||
return ctx.root
|
||||
|
||||
|
||||
def _backend_ctx_for(ptype: ProjectType, ctx: RepoCtx) -> RepoCtx:
|
||||
"""Build a RepoCtx rooted at the backend root (FULLSTACK → ``backend/``).
|
||||
|
||||
Convenience wrapper: returns a fresh context with ``root=backend_root`` so
|
||||
backend-source checks (pyproject, hatch packages, python-version, ...) read
|
||||
from the right tree without each function re-deriving the path.
|
||||
"""
|
||||
return RepoCtx(root=_backend_root_for(ptype, ctx), config=ctx.config)
|
||||
|
||||
|
||||
def path_exists(rel: str, ctx: RepoCtx | None = None) -> bool:
|
||||
"""True if ``root / rel`` exists (``ctx`` preferred, else module global)."""
|
||||
root = ctx.root if ctx is not None else REPO_ROOT
|
||||
|
|
@ -258,9 +285,38 @@ def _resolve_package_name(ctx: RepoCtx | None = None) -> str | None:
|
|||
return _normalize_package_name(name)
|
||||
|
||||
|
||||
# DB-capability markers — presence of any of these in ``[project.dependencies]``
|
||||
# marks the project as a db-project (``db/models`` required). Auto-detection
|
||||
# replaces the former ``[tool.project-status] no_db`` marker (issue #274).
|
||||
DB_DEP_MARKERS: tuple[str, ...] = (
|
||||
"tortoise-orm",
|
||||
"sqlalchemy",
|
||||
"sqlmodel",
|
||||
"alembic",
|
||||
"aerich",
|
||||
"pony",
|
||||
"databases",
|
||||
)
|
||||
|
||||
|
||||
def _is_db_project(deps_lower: str) -> bool:
|
||||
"""True if any DB-capability marker is present in the deps string.
|
||||
|
||||
Used to decide whether ``db/models`` is required: a backend/fullstack
|
||||
project without a DB dep (cookiecutter ``use_db=no``) does not need
|
||||
``db/models`` and the structure check yields WARN (not FAIL) when absent.
|
||||
"""
|
||||
return any(marker in deps_lower for marker in DB_DEP_MARKERS)
|
||||
|
||||
|
||||
def _matches_backend(deps_lower: str, ctx: RepoCtx | None = None) -> bool:
|
||||
"""True if nested ``src/<package>/api/v1`` + fastapi/uvicorn in deps.
|
||||
|
||||
Type detection (BACKEND) requires ``fastapi``/``uvicorn`` in deps + the
|
||||
nested ``src/<package>/api/v1`` dir. It does NOT require ``db/models`` —
|
||||
that is a separate DB-capability check (``_is_db_project`` + the
|
||||
``db/models`` structure check, which is WARN when absent).
|
||||
|
||||
Falls back to flat ``src/api/v1`` detection (with the caller surfacing
|
||||
a WARN via ``_check_flat_layout``) when ``pyproject.toml`` is missing.
|
||||
"""
|
||||
|
|
@ -268,8 +324,8 @@ def _matches_backend(deps_lower: str, ctx: RepoCtx | None = None) -> bool:
|
|||
return False
|
||||
pkg = _resolve_package_name(ctx)
|
||||
if pkg is not None:
|
||||
return path_exists(f"src/{pkg}/api/v1", ctx) and path_exists(f"src/{pkg}/db/models", ctx)
|
||||
return path_exists("src/api/v1", ctx) and path_exists("src/db/models", ctx)
|
||||
return path_exists(f"src/{pkg}/api/v1", ctx)
|
||||
return path_exists("src/api/v1", ctx)
|
||||
|
||||
|
||||
def _detect_simple_type(deps_lower: str, ctx: RepoCtx | None = None) -> ProjectType | None:
|
||||
|
|
@ -315,13 +371,13 @@ def detect_project_type(ctx: RepoCtx | None = None) -> ProjectType:
|
|||
STRUCTURE_EXPECTED: dict[str, list[str]] = project_contract.STRUCTURE_EXPECTED
|
||||
|
||||
|
||||
def _expected_backend_paths(pkg: str, no_db: bool = False) -> list[str]:
|
||||
def _expected_backend_paths(pkg: str, db_required: bool) -> list[str]:
|
||||
"""Return the nested ``src/<package>/`` structure for the backend type.
|
||||
|
||||
``pkg`` is the normalized ``[project].name`` (``my-project`` → ``my_project``).
|
||||
When ``no_db`` is true (``[tool.project-status] no_db = true`` in
|
||||
pyproject.toml), ``db/models`` is excluded from the expected paths —
|
||||
cookiecutter ``use_db=no`` removes the ``db/`` dir.
|
||||
When ``db_required`` is false (no DB dep in pyproject, cookiecutter
|
||||
``use_db=no``), ``db/models`` is excluded from the expected paths — the
|
||||
caller surfaces a WARN separately when a db-project is missing it.
|
||||
"""
|
||||
paths = [
|
||||
f"src/{pkg}/api/v1",
|
||||
|
|
@ -331,7 +387,7 @@ def _expected_backend_paths(pkg: str, no_db: bool = False) -> list[str]:
|
|||
f"src/{pkg}/config/settings.py",
|
||||
"main.py",
|
||||
]
|
||||
if no_db:
|
||||
if not db_required:
|
||||
paths.pop(1) # remove ``src/<pkg>/db/models``
|
||||
return paths
|
||||
|
||||
|
|
@ -358,12 +414,20 @@ README_DELIMITERS: list[str] = [
|
|||
# ── check group 1: structure ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _check_backend_lifespan(ctx: RepoCtx) -> CheckResult:
|
||||
"""Check main.py has a lifespan handler (backend-specific)."""
|
||||
main = read_text("main.py", ctx)
|
||||
if main is None:
|
||||
def _check_backend_lifespan(backend_root: Path) -> CheckResult:
|
||||
"""Check main.py has a lifespan handler (backend-specific).
|
||||
|
||||
``backend_root`` is the backend source tree root (``ctx.root`` for BACKEND,
|
||||
``ctx.root / "backend"`` for FULLSTACK).
|
||||
"""
|
||||
main = backend_root / "main.py"
|
||||
if not main.exists():
|
||||
return CheckResult(CheckStatus.FAIL, "main.py lifespan", "main.py нет")
|
||||
if "lifespan" in main:
|
||||
try:
|
||||
content = main.read_text(encoding="utf-8-sig")
|
||||
except OSError:
|
||||
return CheckResult(CheckStatus.FAIL, "main.py lifespan", "main.py нет")
|
||||
if "lifespan" in content:
|
||||
return CheckResult(CheckStatus.OK, "main.py lifespan", "lifespan найден")
|
||||
return CheckResult(CheckStatus.WARN, "main.py lifespan", "lifespan не найден")
|
||||
|
||||
|
|
@ -392,14 +456,20 @@ def _check_flat_layout(ptype: ProjectType, ctx: RepoCtx) -> CheckResult | None:
|
|||
nested ``src/<package>/`` is the standard for publishable, reusable
|
||||
packages. Flat ``src/`` (with ``api/``, ``db/`` directly) is deprecated.
|
||||
|
||||
For FULLSTACK the backend source lives under ``backend/``, so the check
|
||||
inspects ``backend/src/`` (issue #274). Root-level ``frontend/`` is not
|
||||
affected.
|
||||
|
||||
Returns None if ``src/`` does not exist, has a nested package with
|
||||
``__init__.py``, or has a subdir matching ``[project].name`` (normalized).
|
||||
Returns a WARN CheckResult if flat layout detected.
|
||||
"""
|
||||
src = ctx.root / "src"
|
||||
backend_root = _backend_root_for(ptype, ctx)
|
||||
src = backend_root / "src"
|
||||
if not src.exists() or not src.is_dir():
|
||||
return None
|
||||
pyproject = parse_pyproject(ctx)
|
||||
backend_ctx = RepoCtx(root=backend_root, config=ctx.config)
|
||||
pyproject = parse_pyproject(backend_ctx)
|
||||
project = pyproject.get("project", {}) if isinstance(pyproject, dict) else {}
|
||||
proj_name = project.get("name") if isinstance(project, dict) else None
|
||||
expected_pkg = _normalize_package_name(str(proj_name)) if proj_name else None
|
||||
|
|
@ -467,13 +537,17 @@ def _file_imports_tortoise_or_models(source: str, pkg: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
def _scan_file_for_models(py_file: Path, ctx: RepoCtx, pkg: str) -> list[CheckResult]:
|
||||
def _scan_file_for_models(py_file: Path, backend_root: Path, pkg: str) -> list[CheckResult]:
|
||||
"""Scan a single ``.py`` file for ORM ``class X(Model)`` definitions.
|
||||
|
||||
Returns one ``WARN`` CheckResult per offending class (with the file's
|
||||
relative path). Returns ``[]`` if the file does not import tortoise or
|
||||
the package's own ``db.models`` module (false-positive guard), or if no
|
||||
class inherits from ``Model``.
|
||||
|
||||
``backend_root`` is used for the relative path in the WARN detail (issue
|
||||
#274): FULLSTACK reports paths relative to ``backend/``, not the repo
|
||||
root, so the message matches what the developer sees in their tree.
|
||||
"""
|
||||
try:
|
||||
source = py_file.read_text(encoding="utf-8-sig")
|
||||
|
|
@ -486,7 +560,7 @@ def _scan_file_for_models(py_file: Path, ctx: RepoCtx, pkg: str) -> list[CheckRe
|
|||
except SyntaxError:
|
||||
return []
|
||||
results: list[CheckResult] = []
|
||||
rel = py_file.relative_to(ctx.root)
|
||||
rel = py_file.relative_to(backend_root)
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.ClassDef):
|
||||
continue
|
||||
|
|
@ -513,21 +587,20 @@ def _check_scattered_models(ctx: RepoCtx, ptype: ProjectType) -> list[CheckResul
|
|||
import ``tortoise`` or the package's own ``db.models`` module are skipped
|
||||
(suppresses false positives from unrelated libraries defining ``Model``).
|
||||
|
||||
Skips for non-backend/fullstack types, flat layouts (no ``src/<package>/``),
|
||||
and repos without a resolvable package name. Returns ``[]`` in all skip
|
||||
cases so the caller appends nothing.
|
||||
For FULLSTACK the backend lives under ``backend/`` (issue #274): the
|
||||
package name is resolved against ``backend/pyproject.toml`` and the scan
|
||||
targets ``backend/src/<package>/``. Skips for non-backend/fullstack types,
|
||||
flat layouts (no ``src/<package>/``), and repos without a resolvable
|
||||
package name. Returns ``[]`` in all skip cases so the caller appends nothing.
|
||||
"""
|
||||
if ptype not in {ProjectType.BACKEND, ProjectType.FULLSTACK}:
|
||||
return []
|
||||
pkg = _resolve_package_name(ctx)
|
||||
backend_root = _backend_root_for(ptype, ctx)
|
||||
backend_ctx = RepoCtx(root=backend_root, config=ctx.config)
|
||||
pkg = _resolve_package_name(backend_ctx)
|
||||
if pkg is None:
|
||||
return []
|
||||
# For FULLSTACK, the backend lives under ``backend/``; for BACKEND, at root.
|
||||
src_pkg = ctx.root / "src" / pkg
|
||||
if ptype == ProjectType.FULLSTACK:
|
||||
backend_src = ctx.root / "backend" / "src" / pkg
|
||||
if backend_src.is_dir():
|
||||
src_pkg = backend_src
|
||||
src_pkg = backend_root / "src" / pkg
|
||||
if not src_pkg.is_dir():
|
||||
return []
|
||||
models_dir = src_pkg / "db" / "models"
|
||||
|
|
@ -535,7 +608,7 @@ def _check_scattered_models(ctx: RepoCtx, ptype: ProjectType) -> list[CheckResul
|
|||
for py_file in src_pkg.rglob("*.py"):
|
||||
if models_dir in py_file.parents or py_file == models_dir:
|
||||
continue
|
||||
results.extend(_scan_file_for_models(py_file, ctx, pkg))
|
||||
results.extend(_scan_file_for_models(py_file, backend_root, pkg))
|
||||
return results
|
||||
|
||||
|
||||
|
|
@ -587,13 +660,15 @@ def _check_frontend_stack(ctx: RepoCtx) -> CheckResult | None:
|
|||
def _check_type_specific_structure(ptype: ProjectType, ctx: RepoCtx) -> list[CheckResult]:
|
||||
"""Type-specific extra checks beyond the expected dirs list."""
|
||||
results: list[CheckResult] = []
|
||||
backend_root = _backend_root_for(ptype, ctx)
|
||||
if ptype == ProjectType.BACKEND:
|
||||
results.append(_check_backend_lifespan(ctx))
|
||||
results.append(_check_backend_lifespan(backend_root))
|
||||
if ptype == ProjectType.FULLSTACK and not path_exists("frontend/package.json", ctx):
|
||||
results.append(
|
||||
CheckResult(CheckStatus.WARN, "frontend/package.json", "SvelteKit не обнаружен")
|
||||
)
|
||||
if ptype == ProjectType.FULLSTACK:
|
||||
results.append(_check_backend_lifespan(backend_root))
|
||||
frontend_stack = _check_frontend_stack(ctx)
|
||||
if frontend_stack is not None:
|
||||
results.append(frontend_stack)
|
||||
|
|
@ -607,10 +682,21 @@ def _check_type_specific_structure(ptype: ProjectType, ctx: RepoCtx) -> list[Che
|
|||
|
||||
|
||||
def check_structure(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
|
||||
"""Group 1: Structure — expected dirs/files per project type."""
|
||||
"""Group 1: Structure — expected dirs/files per project type.
|
||||
|
||||
For BACKEND and FULLSTACK the expected paths are rooted at the backend
|
||||
source tree (``ctx.root`` for BACKEND, ``ctx.root / "backend"`` for
|
||||
FULLSTACK — issue #274). ``db/models`` is auto-detected from
|
||||
``[project.dependencies]``: if any DB marker (tortoise-orm, sqlalchemy,
|
||||
sqlmodel, alembic, aerich, pony, databases) is present, ``db/models`` is
|
||||
expected and a missing dir yields WARN (not FAIL — issue #275); if no
|
||||
DB marker is present, ``db/models`` is skipped (cookiecutter use_db=no).
|
||||
"""
|
||||
group = GroupResult(name="Структура")
|
||||
if ptype == ProjectType.BACKEND:
|
||||
pkg = _resolve_package_name(ctx)
|
||||
if ptype in {ProjectType.BACKEND, ProjectType.FULLSTACK}:
|
||||
backend_root = _backend_root_for(ptype, ctx)
|
||||
backend_ctx = RepoCtx(root=backend_root, config=ctx.config)
|
||||
pkg = _resolve_package_name(backend_ctx)
|
||||
if pkg is None:
|
||||
group.checks.append(
|
||||
CheckResult(
|
||||
|
|
@ -620,19 +706,40 @@ def check_structure(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
|
|||
)
|
||||
)
|
||||
return group
|
||||
no_db = bool(ctx.config.get("no_db", False))
|
||||
expected = _expected_backend_paths(pkg, no_db=no_db)
|
||||
deps_raw = parse_pyproject(backend_ctx).get("project", {}).get("dependencies", [])
|
||||
deps_lower = (
|
||||
" ".join(str(d).lower() for d in deps_raw) if isinstance(deps_raw, list) else ""
|
||||
)
|
||||
db_required = _is_db_project(deps_lower)
|
||||
expected = _expected_backend_paths(pkg, db_required=db_required)
|
||||
for rel in expected:
|
||||
status = CheckStatus.OK if (backend_root / rel).exists() else CheckStatus.FAIL
|
||||
detail = "существует" if status == CheckStatus.OK else "отсутствует"
|
||||
group.checks.append(CheckResult(status, rel, detail))
|
||||
if db_required:
|
||||
db_models_rel = f"src/{pkg}/db/models"
|
||||
if not (backend_root / db_models_rel).exists():
|
||||
# Replace the FAIL above with a WARN — db/models missing is
|
||||
# not a contract violation (issue #275: all-WARN contract).
|
||||
group.checks = [
|
||||
c
|
||||
if c.name != db_models_rel
|
||||
else CheckResult(
|
||||
CheckStatus.WARN, db_models_rel, "отсутствует (db-проект без db/models)"
|
||||
)
|
||||
for c in group.checks
|
||||
]
|
||||
else:
|
||||
expected = STRUCTURE_EXPECTED.get(ptype.value, [])
|
||||
if not expected:
|
||||
group.checks.append(
|
||||
CheckResult(CheckStatus.WARN, "auto-detect", f"тип={ptype.value}: нет контракта")
|
||||
)
|
||||
return group
|
||||
for rel in expected:
|
||||
status = CheckStatus.OK if path_exists(rel, ctx) else CheckStatus.FAIL
|
||||
detail = "существует" if status == CheckStatus.OK else "отсутствует"
|
||||
group.checks.append(CheckResult(status, rel, detail))
|
||||
if not expected:
|
||||
group.checks.append(
|
||||
CheckResult(CheckStatus.WARN, "auto-detect", f"тип={ptype.value}: нет контракта")
|
||||
)
|
||||
return group
|
||||
for rel in expected:
|
||||
status = CheckStatus.OK if path_exists(rel, ctx) else CheckStatus.FAIL
|
||||
detail = "существует" if status == CheckStatus.OK else "отсутствует"
|
||||
group.checks.append(CheckResult(status, rel, detail))
|
||||
group.checks.extend(_check_type_specific_structure(ptype, ctx))
|
||||
return group
|
||||
|
||||
|
|
@ -763,7 +870,11 @@ def _scan_route_imports(api_dirs: list[Path], ctx: RepoCtx) -> list[CheckResult]
|
|||
|
||||
|
||||
def check_thin_routes(ptype: ProjectType, ctx: RepoCtx, fast: bool = False) -> GroupResult:
|
||||
"""Group 2: Тонкие роуты — AST imports (FAIL) + line count (WARN)."""
|
||||
"""Group 2: Тонкие роуты — AST imports (FAIL) + line count (WARN).
|
||||
|
||||
For FULLSTACK the api dirs live under ``backend/src/<pkg>/api/v1`` and
|
||||
WARN paths are reported relative to ``backend/`` (issue #274).
|
||||
"""
|
||||
_ = fast # unused here, accepted for signature uniformity
|
||||
group = GroupResult(name="Тонкие роуты")
|
||||
if ptype not in {ProjectType.BACKEND, ProjectType.FULLSTACK}:
|
||||
|
|
@ -777,7 +888,8 @@ def check_thin_routes(ptype: ProjectType, ctx: RepoCtx, fast: bool = False) -> G
|
|||
CheckResult(CheckStatus.WARN, "src/api/v1/", "директория роутов не найдена")
|
||||
)
|
||||
return group
|
||||
_append_route_checks(group, api_dirs, ctx)
|
||||
backend_ctx = _backend_ctx_for(ptype, ctx)
|
||||
_append_route_checks(group, api_dirs, backend_ctx)
|
||||
return group
|
||||
|
||||
|
||||
|
|
@ -817,9 +929,14 @@ def _append_route_checks(group: GroupResult, api_dirs: list[Path], ctx: RepoCtx)
|
|||
|
||||
|
||||
def check_quality(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
|
||||
"""Group 3: Качество кода — mypy/ruff/pytest configured in pyproject.toml."""
|
||||
"""Group 3: Качество кода — mypy/ruff/pytest configured in pyproject.toml.
|
||||
|
||||
Reads ``pyproject.toml`` from the backend root (FULLSTACK →
|
||||
``backend/pyproject.toml`` — issue #274).
|
||||
"""
|
||||
group = GroupResult(name="Качество кода")
|
||||
pyproject = parse_pyproject(ctx)
|
||||
backend_ctx = _backend_ctx_for(ptype, ctx)
|
||||
pyproject = parse_pyproject(backend_ctx)
|
||||
tools = pyproject.get("tool", {})
|
||||
for tool_name in ("ruff", "mypy"):
|
||||
if tool_name in tools:
|
||||
|
|
@ -944,9 +1061,14 @@ def _check_stub_files(test_files: list[Path]) -> CheckResult:
|
|||
|
||||
|
||||
def check_tests(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
|
||||
"""Group 4: Тесты — conftest, structure, no @pytest.mark.asyncio, stubs."""
|
||||
"""Group 4: Тесты — conftest, structure, no @pytest.mark.asyncio, stubs.
|
||||
|
||||
For BACKEND and FULLSTACK the tests live under the backend root (FULLSTACK
|
||||
→ ``backend/tests/`` — issue #274).
|
||||
"""
|
||||
group = GroupResult(name="Тесты")
|
||||
tests_dir = ctx.root / "tests"
|
||||
backend_root = _backend_root_for(ptype, ctx)
|
||||
tests_dir = backend_root / "tests"
|
||||
if not tests_dir.exists():
|
||||
group.checks.append(
|
||||
CheckResult(CheckStatus.FAIL, "tests/", "директория tests/ отсутствует")
|
||||
|
|
@ -1157,9 +1279,14 @@ def check_infra(
|
|||
|
||||
|
||||
def check_coverage(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
|
||||
"""Group 7: Coverage — non-blocking (always OK/WARN, never FAIL)."""
|
||||
"""Group 7: Coverage — non-blocking (always OK/WARN, never FAIL).
|
||||
|
||||
Reads ``pyproject.toml`` from the backend root (FULLSTACK →
|
||||
``backend/pyproject.toml`` — issue #274).
|
||||
"""
|
||||
group = GroupResult(name="Coverage")
|
||||
pyproject = parse_pyproject(ctx)
|
||||
backend_ctx = _backend_ctx_for(ptype, ctx)
|
||||
pyproject = parse_pyproject(backend_ctx)
|
||||
cov = pyproject.get("tool", {}).get("coverage", {})
|
||||
run_cfg = cov.get("run", {}) if isinstance(cov, dict) else {}
|
||||
sources = run_cfg.get("source", []) if isinstance(run_cfg, dict) else []
|
||||
|
|
@ -1495,9 +1622,15 @@ def check_pyproject(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
|
|||
Thin orchestrator: loads + parses ``pyproject.toml``, dispatches each
|
||||
of the 13 sub-checks (``PYPROJECT_CHECKS``), collects results. If the
|
||||
file is missing → single WARN; parse error → single FAIL.
|
||||
|
||||
Reads ``pyproject.toml`` from the backend root (FULLSTACK →
|
||||
``backend/pyproject.toml`` — issue #274); root-level files checked by
|
||||
sub-checks (``.pre-commit-config.yaml``, ``uv.lock``, ``.python-version``,
|
||||
``ruff.toml``, ``mypy.ini``) also resolve against the backend root.
|
||||
"""
|
||||
group = GroupResult(name="Pyproject")
|
||||
data = _load_pyproject(ctx.root)
|
||||
backend_root = _backend_root_for(ptype, ctx)
|
||||
data = _load_pyproject(backend_root)
|
||||
if data is None:
|
||||
group.checks.append(
|
||||
CheckResult(CheckStatus.WARN, "pyproject.toml", "нет — skip Python checks")
|
||||
|
|
@ -1515,7 +1648,7 @@ def check_pyproject(ptype: ProjectType, ctx: RepoCtx) -> GroupResult:
|
|||
tools = data.get("tool", {}) if isinstance(data, dict) else {}
|
||||
pytest_opts = tools.get("pytest", {}).get("ini_options", {}) if isinstance(tools, dict) else {}
|
||||
for check_fn in PYPROJECT_CHECKS:
|
||||
group.checks.append(check_fn(data, ctx.root, ptype, project, tools, pytest_opts))
|
||||
group.checks.append(check_fn(data, backend_root, ptype, project, tools, pytest_opts))
|
||||
return group
|
||||
|
||||
|
||||
|
|
@ -1652,6 +1785,10 @@ def main() -> None:
|
|||
ctx = RepoCtx(root=root, config=load_config(root))
|
||||
|
||||
ptype = detect_project_type(ctx)
|
||||
# Populate ``backend_root`` (FULLSTACK → ``root / "backend"``, else root)
|
||||
# so checks reading backend-source files resolve to the right tree. The
|
||||
# frozen ctx is rebuilt with the resolved field (issue #274).
|
||||
ctx = RepoCtx(root=ctx.root, config=ctx.config, backend_root=_backend_root_for(ptype, ctx))
|
||||
groups = run_all_checks(ptype, ctx, fast=fast, repo_root=root if repo_arg else None)
|
||||
print(format_output(ptype, groups))
|
||||
if strict and any(g.overall() == CheckStatus.FAIL for g in groups):
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Project contract: single source of truth for project types, stacks,
|
||||
expected structure, frontend markers and no_db support.
|
||||
expected structure, frontend markers.
|
||||
|
||||
Imported by both ``spec-status.py`` and ``project-status.py`` via
|
||||
``importlib.util.spec_from_file_location`` (no ``sys.path`` mutation).
|
||||
|
|
@ -12,7 +12,6 @@ Contract symbols:
|
|||
STACK_REQUIRED — dict[type -> list[str]] of mandatory stack items
|
||||
STRUCTURE_EXPECTED — dict[type -> list[str]] of expected top-level dirs/files
|
||||
FRONTEND_STACK_MARKERS — dict with keys for fullstack frontend detection
|
||||
NO_DB_SUPPORTED — set[str] of types that support no_db=true
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -79,9 +78,3 @@ FRONTEND_STACK_MARKERS: dict[str, list[str]] = {
|
|||
"fullstack_package_deps": ["tailwindcss", "bits-ui"],
|
||||
"fullstack_files": ["frontend/components.json", "frontend/tsconfig.json"],
|
||||
}
|
||||
|
||||
# Project types that support ``[tool.project-status] no_db = true`` in
|
||||
# pyproject.toml (skip ``db/models`` check). Backend is the primary case;
|
||||
# fullstack is included for completeness (latent — _check_scattered_models
|
||||
# already early-returns when db/models absent).
|
||||
NO_DB_SUPPORTED: set[str] = {"backend", "fullstack"}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ so the rendered tree only contains the parts the user asked for.
|
|||
- ``use_auth == "no"`` -> drop ``routes/auth.py``, ``services/auth_service.py``,
|
||||
``models/user.py`` (hashed_password), and strip the auth dependency wiring.
|
||||
- ``use_db == "no"`` -> drop ``db/``, ``migrations/``, ``connection.py``.
|
||||
The DB dep (``tortoise-orm``/``asyncpg``) is conditionally rendered in
|
||||
``pyproject.toml`` (jinja if-block on use_db), so no marker needs
|
||||
to be written: the project-status oracle auto-detects db-projects from
|
||||
deps (issue #274).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -33,7 +37,7 @@ def main() -> None:
|
|||
if use_auth == "no":
|
||||
_remove(f"src/{pkg}/api/v1/routes/auth.py")
|
||||
_remove(f"src/{pkg}/services/auth_service.py")
|
||||
_remove(f"tests/test_auth.py")
|
||||
_remove("tests/test_auth.py")
|
||||
|
||||
if use_db == "no":
|
||||
# db is the root cause for the broken-conditional findings: files
|
||||
|
|
@ -46,21 +50,14 @@ def main() -> None:
|
|||
_remove(f"src/{pkg}/api/v1/routes/users.py")
|
||||
_remove(f"src/{pkg}/api/v1/dependencies.py")
|
||||
_remove(f"src/{pkg}/schemas/user.py")
|
||||
_remove(f"tests/unit/test_user.py")
|
||||
_remove(f"tests/unit/test_user_service.py")
|
||||
_remove(f"tests/api/test_users.py")
|
||||
_remove("tests/unit/test_user.py")
|
||||
_remove("tests/unit/test_user_service.py")
|
||||
_remove("tests/api/test_users.py")
|
||||
if use_auth == "yes":
|
||||
# auth_service imports User; strip it and its wiring too.
|
||||
_remove(f"src/{pkg}/api/v1/routes/auth.py")
|
||||
_remove(f"src/{pkg}/services/auth_service.py")
|
||||
_remove(f"tests/test_auth.py")
|
||||
# Write ``no_db = true`` into the existing ``[tool.project-status]``
|
||||
# section so the project-status oracle skips the ``db/models``
|
||||
# structure check for this no-db project (issue #266: no_db polarity
|
||||
# fix). Append the key to the section at the end of the file.
|
||||
pyproject = PROJECT_DIR / "pyproject.toml"
|
||||
with open(pyproject, "a") as f:
|
||||
f.write("\nno_db = true\n")
|
||||
_remove("tests/test_auth.py")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ def main() -> None:
|
|||
if use_auth == "no":
|
||||
_remove(f"backend/src/{pkg}/api/v1/routes/auth.py")
|
||||
_remove(f"backend/src/{pkg}/services/auth_service.py")
|
||||
_remove(f"backend/tests/test_auth.py")
|
||||
_remove("backend/tests/test_auth.py")
|
||||
|
||||
if use_db == "no":
|
||||
# db is the root cause for the broken-conditional findings: files
|
||||
|
|
@ -42,21 +42,14 @@ def main() -> None:
|
|||
_remove(f"backend/src/{pkg}/api/v1/routes/users.py")
|
||||
_remove(f"backend/src/{pkg}/api/v1/dependencies.py")
|
||||
_remove(f"backend/src/{pkg}/schemas/user.py")
|
||||
_remove(f"backend/tests/unit/test_user.py")
|
||||
_remove(f"backend/tests/unit/test_user_service.py")
|
||||
_remove(f"backend/tests/api/test_users.py")
|
||||
_remove("backend/tests/unit/test_user.py")
|
||||
_remove("backend/tests/unit/test_user_service.py")
|
||||
_remove("backend/tests/api/test_users.py")
|
||||
if use_auth == "yes":
|
||||
# auth_service imports User; strip it and its wiring too.
|
||||
_remove(f"backend/src/{pkg}/api/v1/routes/auth.py")
|
||||
_remove(f"backend/src/{pkg}/services/auth_service.py")
|
||||
_remove(f"backend/tests/test_auth.py")
|
||||
# Write ``no_db = true`` into the existing ``[tool.project-status]``
|
||||
# section so the project-status oracle skips the ``db/models``
|
||||
# structure check for this no-db project (issue #266: no_db polarity
|
||||
# fix). Append the key to the section at the end of the file.
|
||||
pyproject = PROJECT_DIR / "backend" / "pyproject.toml"
|
||||
with open(pyproject, "a") as f:
|
||||
f.write("\nno_db = true\n")
|
||||
_remove("backend/tests/test_auth.py")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
|
|
|||
|
|
@ -613,18 +613,57 @@ def test_infra_checks_present(render):
|
|||
|
||||
@pytest.mark.parametrize(
|
||||
"template_name, extra_context",
|
||||
[("fullstack", {"project_name": "fs"})],
|
||||
[
|
||||
("fullstack", {"project_name": "fs", "use_db": "yes", "use_auth": "yes"}),
|
||||
("fullstack", {"project_name": "fs", "use_db": "no", "use_auth": "yes"}),
|
||||
],
|
||||
)
|
||||
def test_fullstack_passes_project_status_structure(render):
|
||||
"""Fullstack is detected as fullstack (backend/ + frontend/ present)."""
|
||||
def test_fullstack_passes_project_status_all_groups(render, extra_context):
|
||||
"""Fullstack template passes the project-status groups (issue #274).
|
||||
|
||||
With ``use_db=yes`` all backend-source checks (Structure, Тонкие роуты,
|
||||
Качество кода, Тесты, Pyproject, Coverage, Infra) resolve against
|
||||
``backend/`` and yield OK/WARN (never FAIL). With ``use_db=no`` the db
|
||||
dep is absent from ``pyproject.toml``, so ``db/models`` is auto-skipped
|
||||
(no FAIL) and the project still detects as FULLSTACK; ``Тесты`` may FAIL
|
||||
because the use_db=no hook strips all db-related test files (test_auth.py
|
||||
included when use_auth=yes, since auth_service imports the removed User).
|
||||
|
||||
README is intentionally absent (issue #269 → WARN, non-blocking) and is
|
||||
not asserted here. Branch protection is skipped via ``--fast`` (WARN).
|
||||
"""
|
||||
ptype = ps.ProjectType.FULLSTACK
|
||||
ctx = ps.RepoCtx(root=render, config=ps.load_config(render))
|
||||
detected = ps.detect_project_type(ctx)
|
||||
assert detected == ptype
|
||||
_, checks = _run_status_checks(render)
|
||||
by_name = {c.name: c for c in checks}
|
||||
assert by_name["backend"].status == ps.CheckStatus.OK
|
||||
assert by_name["frontend"].status == ps.CheckStatus.OK
|
||||
ctx = ps.RepoCtx(root=render, config=ctx.config, backend_root=ps._backend_root_for(ptype, ctx))
|
||||
groups = ps.run_all_checks(ptype, ctx, fast=True)
|
||||
by_name = {g.name: g for g in groups}
|
||||
# Structure: backend/ + frontend/ present, nested src/<pkg>/api/v1 OK.
|
||||
structure = by_name["Структура"]
|
||||
assert structure.overall() != ps.CheckStatus.FAIL, (
|
||||
f"Структура FAIL: {[c for c in structure.checks if c.status == ps.CheckStatus.FAIL]}"
|
||||
)
|
||||
# FULLSTACK backend-source checks are rooted at backend/ (issue #274):
|
||||
# the expected paths are src/<pkg>/api/v1, schemas, services, ... — and
|
||||
# root-level backend/ + frontend/ dirs are present (detection requirement).
|
||||
assert (render / "backend").is_dir(), "fullstack requires backend/ dir"
|
||||
assert (render / "frontend").is_dir(), "fullstack requires frontend/ dir"
|
||||
assert any(
|
||||
c.name == "src/fs/api/v1" and c.status == ps.CheckStatus.OK for c in structure.checks
|
||||
), f"src/fs/api/v1 not OK: {structure.checks}"
|
||||
# Groups that must never FAIL: Quality, Pyproject, Coverage, Infra, routes.
|
||||
# Тесты is OK for use_db=yes (test_auth.py kept); for use_db=no it may FAIL
|
||||
# (the hook strips db-related tests, leaving no test_*.py at tests/ root).
|
||||
must_pass = ["Тонкие роуты", "Качество кода", "Pyproject", "Coverage", "Infra"]
|
||||
if extra_context.get("use_db") == "yes":
|
||||
must_pass.append("Тесты")
|
||||
for group_name in must_pass:
|
||||
g = by_name[group_name]
|
||||
failed = [c for c in g.checks if c.status == ps.CheckStatus.FAIL]
|
||||
assert g.overall() != ps.CheckStatus.FAIL, f"{group_name} FAIL: " + ", ".join(
|
||||
f"{c.name}={c.status.value}" for c in failed
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("template_name, extra_context", [("cli", {"project_name": "cl"})])
|
||||
|
|
@ -968,40 +1007,7 @@ def test_cookiecutter_json_default_is_valid_identifier(template_name):
|
|||
)
|
||||
|
||||
|
||||
# ── issue #266: cookiecutter use_db=no writes no_db marker ─────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"template_name, extra_context",
|
||||
[("backend", {"project_name": "be", "use_db": "no", "use_auth": "no"})],
|
||||
)
|
||||
def test_backend_use_db_no_has_no_db_in_pyproject(render):
|
||||
"""Backend rendered with ``use_db=no`` contains ``no_db = true`` in
|
||||
``[tool.project-status]`` section of ``pyproject.toml`` (issue #266).
|
||||
|
||||
The post-gen hook appends ``no_db = true`` to the existing
|
||||
``[tool.project-status]`` section so the project-status oracle skips
|
||||
the ``db/models`` structure check.
|
||||
"""
|
||||
pyproject = (render / "pyproject.toml").read_text()
|
||||
assert "[tool.project-status]" in pyproject
|
||||
assert "no_db = true" in pyproject, (
|
||||
f"expected 'no_db = true' in [tool.project-status], got:\n{pyproject}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"template_name, extra_context",
|
||||
[("fullstack", {"project_name": "fs", "use_db": "no", "use_auth": "no"})],
|
||||
)
|
||||
def test_fullstack_use_db_no_has_no_db_in_pyproject(render):
|
||||
"""Fullstack rendered with ``use_db=no`` contains ``no_db = true`` in
|
||||
``[tool.project-status]`` section of ``backend/pyproject.toml``."""
|
||||
pyproject = (render / "backend" / "pyproject.toml").read_text()
|
||||
assert "[tool.project-status]" in pyproject
|
||||
assert "no_db = true" in pyproject, (
|
||||
f"expected 'no_db = true' in [tool.project-status], got:\n{pyproject}"
|
||||
)
|
||||
# ── issue #274: cookiecutter use_db=no removes db dep (auto-detect) ─────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
"""Tests for ``.opencode/scripts/project_contract.py`` — single source of
|
||||
truth for project types, stacks, structure, frontend markers, no_db.
|
||||
truth for project types, stacks, structure, frontend markers.
|
||||
|
||||
The contract module is imported via ``importlib.util`` (no ``sys.path``
|
||||
mutation), mirroring how ``spec-status.py`` and ``project-status.py`` load it.
|
||||
|
|
@ -86,12 +86,6 @@ def test_structure_expected_keys_subset():
|
|||
assert "mcp-server" not in keys
|
||||
|
||||
|
||||
def test_no_db_supported_subset():
|
||||
"""NO_DB_SUPPORTED ⊆ VALID_TYPES (backend + fullstack only)."""
|
||||
assert pc.NO_DB_SUPPORTED.issubset(pc.VALID_TYPES)
|
||||
assert {"backend", "fullstack"} == pc.NO_DB_SUPPORTED
|
||||
|
||||
|
||||
def test_frontend_stack_markers_keys():
|
||||
"""FRONTEND_STACK_MARKERS has the expected keys with correct marker lists."""
|
||||
assert set(pc.FRONTEND_STACK_MARKERS.keys()) == {
|
||||
|
|
|
|||
|
|
@ -299,19 +299,6 @@ def test_load_config_defaults_when_no_pyproject(tmp_path):
|
|||
cfg = ps.load_config()
|
||||
assert cfg["route_line_limit"] == 50
|
||||
assert cfg["min_test_count"] == 1
|
||||
assert cfg["no_db"] is False
|
||||
|
||||
|
||||
def test_load_config_reads_no_db(tmp_path):
|
||||
"""``[tool.project-status] no_db = true`` is parsed into config (issue #266)."""
|
||||
monkeypatch = pytest.MonkeyPatch()
|
||||
monkeypatch.setattr(ps, "REPO_ROOT", tmp_path)
|
||||
(tmp_path / "pyproject.toml").write_text(
|
||||
"[tool.project-status]\nroute_line_limit = 80\nno_db = true\n"
|
||||
)
|
||||
cfg = ps.load_config()
|
||||
assert cfg["no_db"] is True
|
||||
assert cfg["route_line_limit"] == 80
|
||||
|
||||
|
||||
def test_load_config_reads_section(tmp_path):
|
||||
|
|
@ -2249,43 +2236,109 @@ def test_fullstack_frontend_stack_missing_tsconfig(tmp_path, ctx):
|
|||
assert "tsconfig.json" in frontend[0].detail
|
||||
|
||||
|
||||
# ── issue #266: no_db polarity fix (backend) ────────────────────────────────
|
||||
# ── issue #274: db/models auto-detect from deps ──────────────────────────────
|
||||
|
||||
|
||||
def test_backend_no_db_skips_db_models(tmp_path, ctx):
|
||||
"""Backend with ``[tool.project-status] no_db = true`` in pyproject.toml
|
||||
→ ``db/models`` is NOT in the expected paths (no FAIL when absent)."""
|
||||
_make_backend_repo(tmp_path)
|
||||
# Remove the db/models dir (simulating cookiecutter use_db=no)
|
||||
shutil.rmtree(tmp_path / "src" / "test_repo" / "db")
|
||||
# Write a [tool.project-status] section with no_db=true into pyproject.toml
|
||||
# (cookiecutter post_gen_project.py appends this to the existing section).
|
||||
pyproject = tmp_path / "pyproject.toml"
|
||||
pyproject.write_text(pyproject.read_text() + "\n[tool.project-status]\nno_db = true\n")
|
||||
# Reload config so no_db is picked up
|
||||
ctx_fresh = ps.RepoCtx(root=tmp_path, config=ps.load_config(tmp_path))
|
||||
assert ctx_fresh.config["no_db"] is True, f"no_db not parsed: {ctx_fresh.config}"
|
||||
group = ps.check_structure(ps.ProjectType.BACKEND, ctx_fresh)
|
||||
# db/models should NOT be among expected checks (skipped by no_db)
|
||||
db_models_checks = [c for c in group.checks if "db/models" in c.name]
|
||||
assert not db_models_checks, (
|
||||
f"db/models should be skipped with no_db=true, got: {db_models_checks}"
|
||||
def test_backend_db_project_missing_db_models_warn(tmp_path, ctx):
|
||||
"""Backend WITH a db dep (tortoise-orm) but no ``db/models`` dir → WARN.
|
||||
|
||||
The db-project is auto-detected from ``[project.dependencies]``; a
|
||||
missing ``db/models`` is a WARN (not FAIL — issue #275: all-WARN
|
||||
contract). Issue #274 removed the ``no_db`` config marker in favor of
|
||||
dependency-based auto-detection.
|
||||
"""
|
||||
pkg = "test_repo"
|
||||
for rel in [
|
||||
f"src/{pkg}/api/v1",
|
||||
f"src/{pkg}/schemas",
|
||||
f"src/{pkg}/services",
|
||||
f"src/{pkg}/config",
|
||||
]:
|
||||
(tmp_path / rel).mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / "src" / pkg / "__init__.py").write_text("")
|
||||
(tmp_path / f"src/{pkg}/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"
|
||||
)
|
||||
# And overall should not FAIL on db/models
|
||||
assert group.overall() != ps.CheckStatus.FAIL or not any(
|
||||
"db/models" in c.name and c.status == ps.CheckStatus.FAIL for c in group.checks
|
||||
), f"db/models FAIL despite no_db=true: {group.checks}"
|
||||
|
||||
|
||||
def test_backend_with_db_requires_db_models(tmp_path, ctx):
|
||||
"""Backend WITHOUT no_db marker → ``db/models`` required (FAIL if absent)."""
|
||||
_make_backend_repo(tmp_path)
|
||||
# Remove the db/models dir
|
||||
shutil.rmtree(tmp_path / "src" / "test_repo" / "db")
|
||||
# No no_db marker in pyproject — default config has no_db=False
|
||||
assert ctx.config.get("no_db", False) is False
|
||||
# db dep present → _is_db_project True → db/models required (WARN if absent)
|
||||
_write_pyproject(tmp_path, deps=["fastapi", "uvicorn", "tortoise-orm"], cov_source=["src"])
|
||||
group = ps.check_structure(ps.ProjectType.BACKEND, ctx)
|
||||
db_models_fail = [
|
||||
c for c in group.checks if "db/models" in c.name and c.status == ps.CheckStatus.FAIL
|
||||
]
|
||||
assert db_models_fail, f"expected db/models FAIL without no_db marker, got: {group.checks}"
|
||||
db_models = [c for c in group.checks if "db/models" in c.name]
|
||||
assert db_models, f"expected db/models check, got: {group.checks}"
|
||||
assert db_models[0].status == ps.CheckStatus.WARN, (
|
||||
f"expected WARN for missing db/models in db-project, got {db_models[0].status}"
|
||||
)
|
||||
|
||||
|
||||
def test_backend_no_db_project_skips_db_models(tmp_path, ctx):
|
||||
"""Backend WITHOUT a db dep (cookiecutter use_db=no) → db/models skipped.
|
||||
|
||||
No db marker in deps → ``_is_db_project`` returns False → ``db/models``
|
||||
is NOT in the expected paths. No FAIL/WARN even if the dir is absent.
|
||||
"""
|
||||
pkg = "test_repo"
|
||||
for rel in [
|
||||
f"src/{pkg}/api/v1",
|
||||
f"src/{pkg}/schemas",
|
||||
f"src/{pkg}/services",
|
||||
f"src/{pkg}/config",
|
||||
]:
|
||||
(tmp_path / rel).mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / "src" / pkg / "__init__.py").write_text("")
|
||||
(tmp_path / f"src/{pkg}/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"
|
||||
)
|
||||
# use_db=no → deps do NOT include a db marker
|
||||
_write_pyproject(tmp_path, deps=["fastapi", "uvicorn"], cov_source=["src"])
|
||||
group = ps.check_structure(ps.ProjectType.BACKEND, ctx)
|
||||
db_models_checks = [c for c in group.checks if "db/models" in c.name]
|
||||
assert not db_models_checks, f"db/models should be skipped (no db dep), got: {db_models_checks}"
|
||||
|
||||
|
||||
def test_fullstack_db_project_missing_db_models_warn(tmp_path, ctx):
|
||||
"""Fullstack WITH a db dep but no ``backend/src/<pkg>/db/models`` → WARN."""
|
||||
_make_fullstack_repo(tmp_path)
|
||||
# _make_fullstack_repo pyproject has fastapi/uvicorn; add a db dep.
|
||||
pyproject = tmp_path / "backend" / "pyproject.toml"
|
||||
content = pyproject.read_text()
|
||||
old_deps = '"fastapi",\n "uvicorn"'
|
||||
new_deps = '"fastapi",\n "uvicorn",\n "tortoise-orm"'
|
||||
pyproject.write_text(content.replace(old_deps, new_deps))
|
||||
shutil.rmtree(tmp_path / "backend" / "src" / "test_repo" / "db")
|
||||
group = ps.check_structure(ps.ProjectType.FULLSTACK, ctx)
|
||||
db_models = [c for c in group.checks if "db/models" in c.name]
|
||||
assert db_models, f"expected db/models check for fullstack, got: {group.checks}"
|
||||
assert db_models[0].status == ps.CheckStatus.WARN, (
|
||||
f"expected WARN for missing db/models in fullstack db-project, got {db_models[0].status}"
|
||||
)
|
||||
|
||||
|
||||
def test_detect_backend_use_db_no(tmp_path, ctx):
|
||||
"""Backend with use_db=no (no db dep) still detects as BACKEND, not UNKNOWN.
|
||||
|
||||
Issue #274: ``_matches_backend`` no longer requires ``db/models`` — only
|
||||
``fastapi``/``uvicorn`` + ``src/<pkg>/api/v1``. A cookiecutter
|
||||
``use_db=no`` repo (no tortoise-orm/asyncpg) is still BACKEND.
|
||||
"""
|
||||
pkg = "test_repo"
|
||||
for rel in [
|
||||
f"src/{pkg}/api/v1",
|
||||
f"src/{pkg}/schemas",
|
||||
f"src/{pkg}/services",
|
||||
f"src/{pkg}/config",
|
||||
]:
|
||||
(tmp_path / rel).mkdir(parents=True, exist_ok=True)
|
||||
(tmp_path / "src" / pkg / "__init__.py").write_text("")
|
||||
(tmp_path / f"src/{pkg}/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"
|
||||
)
|
||||
# use_db=no → deps do NOT include tortoise-orm/asyncpg
|
||||
_write_pyproject(tmp_path, deps=["fastapi", "uvicorn"])
|
||||
assert ps.detect_project_type(ctx) == ps.ProjectType.BACKEND, (
|
||||
"use_db=no backend must detect as BACKEND (db/models not required for type detection)"
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue