From 10df459ceeb113d66dba059e5a7d95a827e08042 Mon Sep 17 00:00:00 2001 From: Sergey <93754860+slaid098@users.noreply.github.com> Date: Mon, 3 Aug 2026 20:06:36 +0300 Subject: [PATCH] fix(infra): enforce nested src// layout in project-status + cookiecutter (#246) * fix(infra): nested src// layout in project-status oracle * fix(templates): nested packages and drop src. prefix in cookiecutter * test(infra): cover nested src// layout contract #241 --------- Co-authored-by: opencode-agent --- .opencode/scripts/project-status.py | 121 +++++++++++++----- .../pyproject.toml | 2 +- .../db/connection.py | 2 +- .../pyproject.toml | 2 +- .../backend/pyproject.toml | 2 +- .../db/connection.py | 2 +- tests/test_cookiecutter_templates.py | 67 ++++++++++ tests/test_project_status.py | 98 ++++++++++---- 8 files changed, 236 insertions(+), 60 deletions(-) diff --git a/.opencode/scripts/project-status.py b/.opencode/scripts/project-status.py index c2b73d5..1a8466b 100644 --- a/.opencode/scripts/project-status.py +++ b/.opencode/scripts/project-status.py @@ -16,11 +16,15 @@ Usage: Project types (auto-detected): fullstack — ``frontend/`` dir (SvelteKit) + ``backend/`` dir - backend — ``src/api/v1/`` + ``src/db/models/`` + fastapi/uvicorn in deps + backend — ``src//api/v1/`` + ``src//db/models/`` + fastapi 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 unknown — none of the above matched (still runs a generic check set) + +```` = ``[project].name`` normalized (``my-project`` → ``my_project``). +Nested ``src//`` is the standard for ALL types (publishable, reusable +as a git-dependency). Flat ``src/`` is deprecated → WARN. """ from __future__ import annotations @@ -214,13 +218,36 @@ def get_repo_full_name(repo: Path | None = None) -> str | None: # ── auto-detect ────────────────────────────────────────────────────────────── +def _resolve_package_name() -> str | None: + """Resolve the project's normalized package name from ``[project].name``. + + Returns ``None`` if ``pyproject.toml`` is missing or has no ``[project].name``. + Normalization: ``my-project`` → ``my_project`` (PEP 503-ish, ``[-_.]+`` → ``_``). + """ + pyproject = parse_pyproject() + if not isinstance(pyproject, dict): + return None + project = pyproject.get("project", {}) + if not isinstance(project, dict): + return None + name = project.get("name") + if not isinstance(name, str) or not name: + return None + return _normalize_package_name(name) + + def _matches_backend(deps_lower: str) -> bool: - """True if backend contract dirs present + fastapi/uvicorn in deps.""" - return ( - path_exists("src/api/v1") - and path_exists("src/db/models") - and ("fastapi" in deps_lower or "uvicorn" in deps_lower) - ) + """True if nested ``src//api/v1`` + fastapi/uvicorn in deps. + + Falls back to flat ``src/api/v1`` detection (with the caller surfacing + a WARN via ``_check_flat_layout``) when ``pyproject.toml`` is missing. + """ + if "fastapi" not in deps_lower and "uvicorn" not in deps_lower: + return False + pkg = _resolve_package_name() + if pkg is not None: + return path_exists(f"src/{pkg}/api/v1") and path_exists(f"src/{pkg}/db/models") + return path_exists("src/api/v1") and path_exists("src/db/models") def _detect_simple_type(deps_lower: str) -> ProjectType | None: @@ -262,14 +289,9 @@ def detect_project_type() -> ProjectType: STRUCTURE_EXPECTED: dict[ProjectType, list[str]] = { - ProjectType.BACKEND: [ - "src/api/v1", - "src/db/models", - "src/schemas", - "src/services", - "src/config/settings.py", - "main.py", - ], + # BACKEND is resolved dynamically by ``_expected_backend_paths`` — the + # paths are ``src//...`` where ```` comes from + # ``[project].name`` normalized (``my-project`` → ``my_project``). ProjectType.FULLSTACK: ["backend", "frontend"], ProjectType.CLI: ["src"], # src// — checked generically ProjectType.BOT: ["src/bot.py"], @@ -278,6 +300,21 @@ STRUCTURE_EXPECTED: dict[ProjectType, list[str]] = { } +def _expected_backend_paths(pkg: str) -> list[str]: + """Return the nested ``src//`` structure for the backend type. + + ``pkg`` is the normalized ``[project].name`` (``my-project`` → ``my_project``). + """ + return [ + f"src/{pkg}/api/v1", + f"src/{pkg}/db/models", + f"src/{pkg}/schemas", + f"src/{pkg}/services", + f"src/{pkg}/config/settings.py", + "main.py", + ] + + # ── README delimiter tags (12) — ported from create-readme.ts:140-199 ─────── @@ -330,16 +367,14 @@ def _normalize_package_name(name: str) -> str: def _check_flat_layout(ptype: ProjectType) -> CheckResult | None: """Check for flat ``src/`` layout (no nested package dir). - Applies only to CLI/UNKNOWN types (publishable-package ambitions). For - backend/bot/worker the ``src/api/``, ``src/bot.py`` layout is an app - contract, not a deprecated flat layout. + Applies to ALL types (backend, fullstack, cli, bot, worker, unknown): + nested ``src//`` is the standard for publishable, reusable + packages. Flat ``src/`` (with ``api/``, ``db/`` directly) is deprecated. 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. """ - if ptype not in {ProjectType.CLI, ProjectType.UNKNOWN}: - return None src = REPO_ROOT / "src" if not src.exists() or not src.is_dir(): return None @@ -380,7 +415,20 @@ def _check_type_specific_structure(ptype: ProjectType) -> list[CheckResult]: def check_structure(ptype: ProjectType) -> GroupResult: """Group 1: Structure — expected dirs/files per project type.""" group = GroupResult(name="Структура") - expected = STRUCTURE_EXPECTED.get(ptype, []) + if ptype == ProjectType.BACKEND: + pkg = _resolve_package_name() + if pkg is None: + group.checks.append( + CheckResult( + CheckStatus.WARN, + "auto-detect", + "нет pyproject.toml — cannot resolve package name", + ) + ) + return group + expected = _expected_backend_paths(pkg) + else: + expected = STRUCTURE_EXPECTED.get(ptype, []) if not expected: group.checks.append( CheckResult(CheckStatus.WARN, "auto-detect", f"тип={ptype.value}: нет контракта") @@ -436,7 +484,10 @@ def _has_route_decorator(decorators: list[ast.expr], route_methods: set[str]) -> def _api_dirs_for(ptype: ProjectType) -> list[Path]: """Return list of api/v1 dirs to scan for routes, based on project type.""" if ptype == ProjectType.BACKEND: - root = REPO_ROOT / "src" / "api" / "v1" + pkg = _resolve_package_name() + if pkg is None: + return [] + root = REPO_ROOT / "src" / pkg / "api" / "v1" return [root] if root.exists() else [] if ptype == ProjectType.FULLSTACK: root = REPO_ROOT / "backend" / "src" / "api" / "v1" @@ -848,10 +899,11 @@ def check_pyproject(ptype: ProjectType) -> GroupResult: # noqa: C901, PLR0912, hatch_packages = hatch_targets.get("packages", []) if isinstance(hatch_targets, dict) else [] src_dir_exists = (REPO_ROOT / "src").exists() if src_pkg_dir_exists: - # nested-layout: packages must reference src/ OR src (hatchling - # accepts both — `["src"]` treats src/ as package root when it - # contains a single package dir matching [project].name). - valid_packages = {src_pkg_path, "src"} + # nested-layout: packages must reference ``src/`` — the only + # valid hatchling config. ``packages = ["src"]`` (flat convention) + # is deprecated → WARN (treats ``src/`` as package root, prevents + # ``my-lib = { git = "..." }`` reuse). + valid_packages = {src_pkg_path} if isinstance(hatch_packages, list) and any(p in valid_packages for p in hatch_packages): group.checks.append( CheckResult( @@ -860,22 +912,31 @@ def check_pyproject(ptype: ProjectType) -> GroupResult: # noqa: C901, PLR0912, f"packages={hatch_packages!r}", ) ) + elif isinstance(hatch_packages, list) and "src" in hatch_packages: + group.checks.append( + CheckResult( + CheckStatus.WARN, + "[tool.hatch.build.targets.wheel]", + f"deprecated flat layout — use packages=['{src_pkg_path}'], " + f"got={hatch_packages!r}", + ) + ) else: group.checks.append( CheckResult( CheckStatus.FAIL, "[tool.hatch.build.targets.wheel]", - f'ожидается packages=["{src_pkg_path}"] или ["src"], got={hatch_packages!r}', + f'ожидается packages=["{src_pkg_path}"], got={hatch_packages!r}', ) ) elif src_dir_exists: - # src/ exists but no src// — app-layout (backend/bot/worker) or - # flat-layout CLI. WARN: not a publishable package layout. + # src/ exists but no src// — deprecated flat layout for ALL + # types. WARN: not a publishable package layout. group.checks.append( CheckResult( CheckStatus.WARN, "[tool.hatch.build.targets.wheel]", - f"нет src/{expected_pkg}/ — app-layout (OK для backend/bot/worker)", + f"нет src/{expected_pkg}/ — deprecated flat layout, use src//", ) ) # No src/ — CLI without library-ambitions diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/pyproject.toml b/.opencode/templates/backend/{{cookiecutter.project_name}}/pyproject.toml index 2c9544e..7198523 100644 --- a/.opencode/templates/backend/{{cookiecutter.project_name}}/pyproject.toml +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/pyproject.toml @@ -50,7 +50,7 @@ Repository = "https://github.com/slaid098/{{ cookiecutter.project_name }}" Issues = "https://github.com/slaid098/{{ cookiecutter.project_name }}/issues" [tool.hatch.build.targets.wheel] -packages = ["src"] +packages = ["src/{{cookiecutter.project_name}}"] # ── Ruff ────────────────────────────────────────────────────────────────── diff --git a/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/db/connection.py b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/db/connection.py index 5a11329..16a689e 100644 --- a/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/db/connection.py +++ b/.opencode/templates/backend/{{cookiecutter.project_name}}/src/{{cookiecutter.project_name}}/db/connection.py @@ -12,7 +12,7 @@ from tortoise import Tortoise from {{ cookiecutter.project_name }}.config.settings import settings -_MODELS_PATH = "src.{{ cookiecutter.project_name }}.db.models" +_MODELS_PATH = "{{ cookiecutter.project_name }}.db.models" async def init_db() -> None: diff --git a/.opencode/templates/cli/{{cookiecutter.project_name}}/pyproject.toml b/.opencode/templates/cli/{{cookiecutter.project_name}}/pyproject.toml index 53f8e73..27f5eb4 100644 --- a/.opencode/templates/cli/{{cookiecutter.project_name}}/pyproject.toml +++ b/.opencode/templates/cli/{{cookiecutter.project_name}}/pyproject.toml @@ -42,7 +42,7 @@ Repository = "https://github.com/slaid098/{{ cookiecutter.project_name }}" Issues = "https://github.com/slaid098/{{ cookiecutter.project_name }}/issues" [tool.hatch.build.targets.wheel] -packages = ["src"] +packages = ["src/{{cookiecutter.project_name}}"] # ── Ruff ────────────────────────────────────────────────────────────────── diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/pyproject.toml b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/pyproject.toml index 2c9544e..7198523 100644 --- a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/pyproject.toml +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/pyproject.toml @@ -50,7 +50,7 @@ Repository = "https://github.com/slaid098/{{ cookiecutter.project_name }}" Issues = "https://github.com/slaid098/{{ cookiecutter.project_name }}/issues" [tool.hatch.build.targets.wheel] -packages = ["src"] +packages = ["src/{{cookiecutter.project_name}}"] # ── Ruff ────────────────────────────────────────────────────────────────── diff --git a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/db/connection.py b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/db/connection.py index 5a11329..16a689e 100644 --- a/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/db/connection.py +++ b/.opencode/templates/fullstack/{{cookiecutter.project_name}}/backend/src/{{cookiecutter.project_name}}/db/connection.py @@ -12,7 +12,7 @@ from tortoise import Tortoise from {{ cookiecutter.project_name }}.config.settings import settings -_MODELS_PATH = "src.{{ cookiecutter.project_name }}.db.models" +_MODELS_PATH = "{{ cookiecutter.project_name }}.db.models" async def init_db() -> None: diff --git a/tests/test_cookiecutter_templates.py b/tests/test_cookiecutter_templates.py index b946d31..fcaf5e9 100644 --- a/tests/test_cookiecutter_templates.py +++ b/tests/test_cookiecutter_templates.py @@ -390,6 +390,73 @@ def test_backend_pytest_asyncio_auto(render): assert 'asyncio_mode = "auto"' in pyproject +# ── nested src// layout (issue #241) ────────────────────────────────── + + +@pytest.mark.parametrize( + "template_name, extra_context", + [ + ("backend", {"project_name": "be"}), + ("cli", {"project_name": "cl"}), + ], +) +def test_pyproject_hatch_packages_nested(render): + """Hatch packages must be ``["src/"]`` (NOT ``["src"]``). + + Issue #241: ``packages = ["src"]`` (flat convention) is deprecated — + only ``packages = ["src/"]`` is valid for a publishable, + reusable-as-git-dep package. + """ + pyproject = (render / "pyproject.toml").read_text() + pkg = extra_context_value(render, "project_name") + assert f'packages = ["src/{pkg}"]' in pyproject, ( + f"expected packages=['src/{pkg}'], got flat or wrong packages" + ) + assert 'packages = ["src"]' not in pyproject, "flat packages=['src'] is deprecated" + + +@pytest.mark.parametrize( + "template_name, extra_context", + [("fullstack", {"project_name": "fs", "use_db": "yes"})], +) +def test_pyproject_hatch_packages_nested_fullstack(render): + """Fullstack backend pyproject must use nested packages (issue #241).""" + pyproject = (render / "backend" / "pyproject.toml").read_text() + assert 'packages = ["src/fs"]' in pyproject + assert 'packages = ["src"]' not in pyproject + + +@pytest.mark.parametrize( + "template_name, extra_context", + [ + ("backend", {"project_name": "be", "use_db": "yes"}), + ("fullstack", {"project_name": "fs", "use_db": "yes"}), + ], +) +def test_db_connection_models_path_no_src_prefix(render, template_name): + """``_MODELS_PATH`` must be ``.db.models`` (NOT ``src....``). + + Issue #241: with nested ``src//`` layout the import path is + ``.db.models`` (no ``src.`` prefix — there is no ``src`` package). + """ + if template_name == "backend": + conn = render / "src" / "be" / "db" / "connection.py" + else: + conn = render / "backend" / "src" / "fs" / "db" / "connection.py" + text = conn.read_text() + assert 'be.db.models"' in text or 'fs.db.models"' in text + assert "src." not in text, "_MODELS_PATH must not use src. prefix (nested layout)" + + +def extra_context_value(render, key): + """Recover the extra_context value for ``key`` from the rendered project. + + The fixtures pass ``project_name`` explicitly; we infer it back from the + top-level dir name (cookiecutter uses it as the project dir). + """ + return render.name + + # ── project-status oracle compatibility ─────────────────────────────────────── diff --git a/tests/test_project_status.py b/tests/test_project_status.py index afab6d7..6b74936 100644 --- a/tests/test_project_status.py +++ b/tests/test_project_status.py @@ -154,22 +154,29 @@ def _write_pyproject( def _make_backend_repo(tmp_path: Path) -> None: - """Create a minimal backend-type repo skeleton in tmp_path.""" + """Create a minimal backend-type repo skeleton in tmp_path. + + Uses the nested ``src//api/v1`` layout (issue #241): the package + name is ``test_repo`` (normalized from the default ``[project].name`` + ``test-repo``). + """ + pkg = "test_repo" for rel in [ - "src/api/v1", - "src/db/models", - "src/schemas", - "src/services", - "src/config", + f"src/{pkg}/api/v1", + f"src/{pkg}/db/models", + f"src/{pkg}/schemas", + f"src/{pkg}/services", + f"src/{pkg}/config", "tests", ]: (tmp_path / rel).mkdir(parents=True, exist_ok=True) - (tmp_path / "src/config/settings.py").write_text("settings = {}\n") + (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" ) - (tmp_path / "src/api/v1/users.py").write_text( + (tmp_path / f"src/{pkg}/api/v1/users.py").write_text( "from fastapi import APIRouter\nrouter = APIRouter()\n" "@router.get('/users')\nasync def list_users():\n return []\n" ) @@ -325,21 +332,26 @@ def test_check_structure_backend_missing_dir(tmp_path): ) 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) + assert any( + c.name == "src/test_repo/api/v1" and c.status == ps.CheckStatus.FAIL for c in group.checks + ) def test_check_structure_backend_no_lifespan(tmp_path): + pkg = "test_repo" for rel in [ - "src/api/v1", - "src/db/models", - "src/schemas", - "src/services", - "src/config", + f"src/{pkg}/api/v1", + f"src/{pkg}/db/models", + f"src/{pkg}/schemas", + f"src/{pkg}/services", + f"src/{pkg}/config", "tests", ]: (tmp_path / rel).mkdir(parents=True, exist_ok=True) - (tmp_path / "src/config/settings.py").write_text("settings = {}\n") + (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("app = None\n") + _write_pyproject(tmp_path, deps=["fastapi", "uvicorn"]) 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 @@ -376,12 +388,21 @@ def test_thin_routes_ok(tmp_path): def test_thin_routes_over_limit(tmp_path): - for rel in ["src/api/v1", "src/db/models", "src/schemas", "src/services", "src/config"]: + pkg = "test_repo" + for rel in [ + f"src/{pkg}/api/v1", + f"src/{pkg}/db/models", + f"src/{pkg}/schemas", + f"src/{pkg}/services", + f"src/{pkg}/config", + ]: (tmp_path / rel).mkdir(parents=True, exist_ok=True) - (tmp_path / "src/config/settings.py").write_text("settings = {}\n") + (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("app = None\n") + _write_pyproject(tmp_path, deps=["fastapi", "uvicorn"]) body = "\n x = 1\n" * 60 - (tmp_path / "src/api/v1/users.py").write_text( + (tmp_path / f"src/{pkg}/api/v1/users.py").write_text( "from fastapi import APIRouter\nrouter = APIRouter()\n" "@router.get('/users')\nasync def list_users():" f"{body} return []\n" @@ -844,21 +865,28 @@ def test_check_pyproject_check2_hatch_wheel_fail_missing_packages(tmp_path): ) -def test_check_pyproject_check2_hatch_wheel_ok_with_src_root(tmp_path): - """packages=["src"] is accepted when src// exists (hatchling convention).""" +def test_check_pyproject_check2_hatch_wheel_warn_with_src_root(tmp_path): + """packages=["src"] is deprecated flat layout → WARN (issue #241). + + Inverted: ``packages = ["src"]`` was previously accepted as OK (hatchling + treats ``src/`` as package root). Now only ``packages = ["src/"]`` + is valid — ``["src"]`` is a deprecated flat layout. + """ (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 + c.name == "[tool.hatch.build.targets.wheel]" + and c.status == ps.CheckStatus.WARN + and "deprecated flat layout" in c.detail for c in group.checks ) def test_check_pyproject_check2_hatch_wheel_warn_app_layout(tmp_path): - """src/ exists but no src// → WARN (app-layout for backend).""" + """src/ exists but no src// → WARN (deprecated flat layout, issue #241).""" (tmp_path / "src").mkdir() (tmp_path / "src" / "api").mkdir() _write_full_pyproject(tmp_path, src_pkg_exists=False) @@ -866,7 +894,7 @@ def test_check_pyproject_check2_hatch_wheel_warn_app_layout(tmp_path): assert any( c.name == "[tool.hatch.build.targets.wheel]" and c.status == ps.CheckStatus.WARN - and "app-layout" in c.detail + and "deprecated flat layout" in c.detail for c in group.checks ) @@ -1176,10 +1204,30 @@ def test_check_structure_flat_layout_warn_for_cli(tmp_path): ) -def test_check_structure_no_flat_warn_for_backend(tmp_path): - """Backend with src/api/ (app-layout) → no flat-layout WARN.""" +def test_check_structure_flat_warn_for_backend(tmp_path): + """Backend with flat src/api/ (no nested package) → WARN flat src/ layout. + + Inverted by issue #241: nested ``src//`` is the standard for + ALL types (backend included). Flat ``src/`` is deprecated → WARN. + """ + 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") + _write_pyproject(tmp_path, deps=["fastapi", "uvicorn"]) + group = ps.check_structure(ps.ProjectType.BACKEND) + assert any( + c.name == "flat src/ layout" and c.status == ps.CheckStatus.WARN for c in group.checks + ) + + +def test_check_structure_backend_nested_ok(tmp_path): + """Backend with nested src//api/v1 → OK (no flat-layout WARN).""" _make_backend_repo(tmp_path) group = ps.check_structure(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 not any(c.name == "flat src/ layout" for c in group.checks)