fix(infra): enforce nested src/<package>/ layout in project-status + cookiecutter (#246)
* fix(infra): nested src/<package>/ layout in project-status oracle * fix(templates): nested packages and drop src. prefix in cookiecutter * test(infra): cover nested src/<package>/ layout contract #241 --------- Co-authored-by: opencode-agent <agent@opencode.local>
This commit is contained in:
parent
596aa9b18a
commit
10df459cee
8 changed files with 236 additions and 60 deletions
|
|
@ -16,11 +16,15 @@ Usage:
|
||||||
|
|
||||||
Project types (auto-detected):
|
Project types (auto-detected):
|
||||||
fullstack — ``frontend/`` dir (SvelteKit) + ``backend/`` dir
|
fullstack — ``frontend/`` dir (SvelteKit) + ``backend/`` dir
|
||||||
backend — ``src/api/v1/`` + ``src/db/models/`` + fastapi/uvicorn in deps
|
backend — ``src/<package>/api/v1/`` + ``src/<package>/db/models/`` + fastapi
|
||||||
cli — ``[project.scripts]`` in pyproject.toml + typer in deps
|
cli — ``[project.scripts]`` in pyproject.toml + typer in deps
|
||||||
bot — ``src/bot.py`` OR aiogram in deps
|
bot — ``src/bot.py`` OR aiogram in deps
|
||||||
worker — ``src/flow.py`` OR prefect in deps
|
worker — ``src/flow.py`` OR prefect in deps
|
||||||
unknown — none of the above matched (still runs a generic check set)
|
unknown — none of the above matched (still runs a generic check set)
|
||||||
|
|
||||||
|
``<package>`` = ``[project].name`` normalized (``my-project`` → ``my_project``).
|
||||||
|
Nested ``src/<package>/`` is the standard for ALL types (publishable, reusable
|
||||||
|
as a git-dependency). Flat ``src/`` is deprecated → WARN.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -214,13 +218,36 @@ def get_repo_full_name(repo: Path | None = None) -> str | None:
|
||||||
# ── auto-detect ──────────────────────────────────────────────────────────────
|
# ── 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:
|
def _matches_backend(deps_lower: str) -> bool:
|
||||||
"""True if backend contract dirs present + fastapi/uvicorn in deps."""
|
"""True if nested ``src/<package>/api/v1`` + fastapi/uvicorn in deps.
|
||||||
return (
|
|
||||||
path_exists("src/api/v1")
|
Falls back to flat ``src/api/v1`` detection (with the caller surfacing
|
||||||
and path_exists("src/db/models")
|
a WARN via ``_check_flat_layout``) when ``pyproject.toml`` is missing.
|
||||||
and ("fastapi" in deps_lower or "uvicorn" in deps_lower)
|
"""
|
||||||
)
|
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:
|
def _detect_simple_type(deps_lower: str) -> ProjectType | None:
|
||||||
|
|
@ -262,14 +289,9 @@ def detect_project_type() -> ProjectType:
|
||||||
|
|
||||||
|
|
||||||
STRUCTURE_EXPECTED: dict[ProjectType, list[str]] = {
|
STRUCTURE_EXPECTED: dict[ProjectType, list[str]] = {
|
||||||
ProjectType.BACKEND: [
|
# BACKEND is resolved dynamically by ``_expected_backend_paths`` — the
|
||||||
"src/api/v1",
|
# paths are ``src/<package_name>/...`` where ``<package_name>`` comes from
|
||||||
"src/db/models",
|
# ``[project].name`` normalized (``my-project`` → ``my_project``).
|
||||||
"src/schemas",
|
|
||||||
"src/services",
|
|
||||||
"src/config/settings.py",
|
|
||||||
"main.py",
|
|
||||||
],
|
|
||||||
ProjectType.FULLSTACK: ["backend", "frontend"],
|
ProjectType.FULLSTACK: ["backend", "frontend"],
|
||||||
ProjectType.CLI: ["src"], # src/<package>/ — checked generically
|
ProjectType.CLI: ["src"], # src/<package>/ — checked generically
|
||||||
ProjectType.BOT: ["src/bot.py"],
|
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/<package>/`` 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 ───────
|
# ── 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:
|
def _check_flat_layout(ptype: ProjectType) -> CheckResult | None:
|
||||||
"""Check for flat ``src/`` layout (no nested package dir).
|
"""Check for flat ``src/`` layout (no nested package dir).
|
||||||
|
|
||||||
Applies only to CLI/UNKNOWN types (publishable-package ambitions). For
|
Applies to ALL types (backend, fullstack, cli, bot, worker, unknown):
|
||||||
backend/bot/worker the ``src/api/``, ``src/bot.py`` layout is an app
|
nested ``src/<package>/`` is the standard for publishable, reusable
|
||||||
contract, not a deprecated flat layout.
|
packages. Flat ``src/`` (with ``api/``, ``db/`` directly) is deprecated.
|
||||||
|
|
||||||
Returns None if ``src/`` does not exist, has a nested package with
|
Returns None if ``src/`` does not exist, has a nested package with
|
||||||
``__init__.py``, or has a subdir matching ``[project].name`` (normalized).
|
``__init__.py``, or has a subdir matching ``[project].name`` (normalized).
|
||||||
Returns a WARN CheckResult if flat layout detected.
|
Returns a WARN CheckResult if flat layout detected.
|
||||||
"""
|
"""
|
||||||
if ptype not in {ProjectType.CLI, ProjectType.UNKNOWN}:
|
|
||||||
return None
|
|
||||||
src = REPO_ROOT / "src"
|
src = REPO_ROOT / "src"
|
||||||
if not src.exists() or not src.is_dir():
|
if not src.exists() or not src.is_dir():
|
||||||
return None
|
return None
|
||||||
|
|
@ -380,7 +415,20 @@ def _check_type_specific_structure(ptype: ProjectType) -> list[CheckResult]:
|
||||||
def check_structure(ptype: ProjectType) -> GroupResult:
|
def check_structure(ptype: ProjectType) -> GroupResult:
|
||||||
"""Group 1: Structure — expected dirs/files per project type."""
|
"""Group 1: Structure — expected dirs/files per project type."""
|
||||||
group = GroupResult(name="Структура")
|
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:
|
if not expected:
|
||||||
group.checks.append(
|
group.checks.append(
|
||||||
CheckResult(CheckStatus.WARN, "auto-detect", f"тип={ptype.value}: нет контракта")
|
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]:
|
def _api_dirs_for(ptype: ProjectType) -> list[Path]:
|
||||||
"""Return list of api/v1 dirs to scan for routes, based on project type."""
|
"""Return list of api/v1 dirs to scan for routes, based on project type."""
|
||||||
if ptype == ProjectType.BACKEND:
|
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 []
|
return [root] if root.exists() else []
|
||||||
if ptype == ProjectType.FULLSTACK:
|
if ptype == ProjectType.FULLSTACK:
|
||||||
root = REPO_ROOT / "backend" / "src" / "api" / "v1"
|
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 []
|
hatch_packages = hatch_targets.get("packages", []) if isinstance(hatch_targets, dict) else []
|
||||||
src_dir_exists = (REPO_ROOT / "src").exists()
|
src_dir_exists = (REPO_ROOT / "src").exists()
|
||||||
if src_pkg_dir_exists:
|
if src_pkg_dir_exists:
|
||||||
# nested-layout: packages must reference src/<pkg> OR src (hatchling
|
# nested-layout: packages must reference ``src/<pkg>`` — the only
|
||||||
# accepts both — `["src"]` treats src/ as package root when it
|
# valid hatchling config. ``packages = ["src"]`` (flat convention)
|
||||||
# contains a single package dir matching [project].name).
|
# is deprecated → WARN (treats ``src/`` as package root, prevents
|
||||||
valid_packages = {src_pkg_path, "src"}
|
# ``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):
|
if isinstance(hatch_packages, list) and any(p in valid_packages for p in hatch_packages):
|
||||||
group.checks.append(
|
group.checks.append(
|
||||||
CheckResult(
|
CheckResult(
|
||||||
|
|
@ -860,22 +912,31 @@ def check_pyproject(ptype: ProjectType) -> GroupResult: # noqa: C901, PLR0912,
|
||||||
f"packages={hatch_packages!r}",
|
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:
|
else:
|
||||||
group.checks.append(
|
group.checks.append(
|
||||||
CheckResult(
|
CheckResult(
|
||||||
CheckStatus.FAIL,
|
CheckStatus.FAIL,
|
||||||
"[tool.hatch.build.targets.wheel]",
|
"[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:
|
elif src_dir_exists:
|
||||||
# src/ exists but no src/<pkg>/ — app-layout (backend/bot/worker) or
|
# src/ exists but no src/<pkg>/ — deprecated flat layout for ALL
|
||||||
# flat-layout CLI. WARN: not a publishable package layout.
|
# types. WARN: not a publishable package layout.
|
||||||
group.checks.append(
|
group.checks.append(
|
||||||
CheckResult(
|
CheckResult(
|
||||||
CheckStatus.WARN,
|
CheckStatus.WARN,
|
||||||
"[tool.hatch.build.targets.wheel]",
|
"[tool.hatch.build.targets.wheel]",
|
||||||
f"нет src/{expected_pkg}/ — app-layout (OK для backend/bot/worker)",
|
f"нет src/{expected_pkg}/ — deprecated flat layout, use src/<package>/",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# No src/ — CLI without library-ambitions
|
# No src/ — CLI without library-ambitions
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ Repository = "https://github.com/slaid098/{{ cookiecutter.project_name }}"
|
||||||
Issues = "https://github.com/slaid098/{{ cookiecutter.project_name }}/issues"
|
Issues = "https://github.com/slaid098/{{ cookiecutter.project_name }}/issues"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src"]
|
packages = ["src/{{cookiecutter.project_name}}"]
|
||||||
|
|
||||||
# ── Ruff ──────────────────────────────────────────────────────────────────
|
# ── Ruff ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from tortoise import Tortoise
|
||||||
|
|
||||||
from {{ cookiecutter.project_name }}.config.settings import settings
|
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:
|
async def init_db() -> None:
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ Repository = "https://github.com/slaid098/{{ cookiecutter.project_name }}"
|
||||||
Issues = "https://github.com/slaid098/{{ cookiecutter.project_name }}/issues"
|
Issues = "https://github.com/slaid098/{{ cookiecutter.project_name }}/issues"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src"]
|
packages = ["src/{{cookiecutter.project_name}}"]
|
||||||
|
|
||||||
# ── Ruff ──────────────────────────────────────────────────────────────────
|
# ── Ruff ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ Repository = "https://github.com/slaid098/{{ cookiecutter.project_name }}"
|
||||||
Issues = "https://github.com/slaid098/{{ cookiecutter.project_name }}/issues"
|
Issues = "https://github.com/slaid098/{{ cookiecutter.project_name }}/issues"
|
||||||
|
|
||||||
[tool.hatch.build.targets.wheel]
|
[tool.hatch.build.targets.wheel]
|
||||||
packages = ["src"]
|
packages = ["src/{{cookiecutter.project_name}}"]
|
||||||
|
|
||||||
# ── Ruff ──────────────────────────────────────────────────────────────────
|
# ── Ruff ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from tortoise import Tortoise
|
||||||
|
|
||||||
from {{ cookiecutter.project_name }}.config.settings import settings
|
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:
|
async def init_db() -> None:
|
||||||
|
|
|
||||||
|
|
@ -390,6 +390,73 @@ def test_backend_pytest_asyncio_auto(render):
|
||||||
assert 'asyncio_mode = "auto"' in pyproject
|
assert 'asyncio_mode = "auto"' in pyproject
|
||||||
|
|
||||||
|
|
||||||
|
# ── nested src/<package>/ 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/<package>"]`` (NOT ``["src"]``).
|
||||||
|
|
||||||
|
Issue #241: ``packages = ["src"]`` (flat convention) is deprecated —
|
||||||
|
only ``packages = ["src/<package>"]`` 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 ``<package>.db.models`` (NOT ``src.<package>...``).
|
||||||
|
|
||||||
|
Issue #241: with nested ``src/<package>/`` layout the import path is
|
||||||
|
``<package>.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 ───────────────────────────────────────
|
# ── project-status oracle compatibility ───────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -154,22 +154,29 @@ def _write_pyproject(
|
||||||
|
|
||||||
|
|
||||||
def _make_backend_repo(tmp_path: Path) -> None:
|
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/<package>/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 [
|
for rel in [
|
||||||
"src/api/v1",
|
f"src/{pkg}/api/v1",
|
||||||
"src/db/models",
|
f"src/{pkg}/db/models",
|
||||||
"src/schemas",
|
f"src/{pkg}/schemas",
|
||||||
"src/services",
|
f"src/{pkg}/services",
|
||||||
"src/config",
|
f"src/{pkg}/config",
|
||||||
"tests",
|
"tests",
|
||||||
]:
|
]:
|
||||||
(tmp_path / rel).mkdir(parents=True, exist_ok=True)
|
(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(
|
(tmp_path / "main.py").write_text(
|
||||||
"from contextlib import asynccontextmanager\n"
|
"from contextlib import asynccontextmanager\n"
|
||||||
"@asynccontextmanager\nasync def lifespan(app): yield\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"
|
"from fastapi import APIRouter\nrouter = APIRouter()\n"
|
||||||
"@router.get('/users')\nasync def list_users():\n return []\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)
|
group = ps.check_structure(ps.ProjectType.BACKEND)
|
||||||
assert group.overall() == ps.CheckStatus.FAIL
|
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):
|
def test_check_structure_backend_no_lifespan(tmp_path):
|
||||||
|
pkg = "test_repo"
|
||||||
for rel in [
|
for rel in [
|
||||||
"src/api/v1",
|
f"src/{pkg}/api/v1",
|
||||||
"src/db/models",
|
f"src/{pkg}/db/models",
|
||||||
"src/schemas",
|
f"src/{pkg}/schemas",
|
||||||
"src/services",
|
f"src/{pkg}/services",
|
||||||
"src/config",
|
f"src/{pkg}/config",
|
||||||
"tests",
|
"tests",
|
||||||
]:
|
]:
|
||||||
(tmp_path / rel).mkdir(parents=True, exist_ok=True)
|
(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")
|
(tmp_path / "main.py").write_text("app = None\n")
|
||||||
|
_write_pyproject(tmp_path, deps=["fastapi", "uvicorn"])
|
||||||
group = ps.check_structure(ps.ProjectType.BACKEND)
|
group = ps.check_structure(ps.ProjectType.BACKEND)
|
||||||
assert any(
|
assert any(
|
||||||
c.name == "main.py lifespan" and c.status == ps.CheckStatus.WARN for c in group.checks
|
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):
|
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 / 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")
|
(tmp_path / "main.py").write_text("app = None\n")
|
||||||
|
_write_pyproject(tmp_path, deps=["fastapi", "uvicorn"])
|
||||||
body = "\n x = 1\n" * 60
|
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"
|
"from fastapi import APIRouter\nrouter = APIRouter()\n"
|
||||||
"@router.get('/users')\nasync def list_users():"
|
"@router.get('/users')\nasync def list_users():"
|
||||||
f"{body} return []\n"
|
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):
|
def test_check_pyproject_check2_hatch_wheel_warn_with_src_root(tmp_path):
|
||||||
"""packages=["src"] is accepted when src/<pkg>/ exists (hatchling convention)."""
|
"""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/<pkg>"]``
|
||||||
|
is valid — ``["src"]`` is a deprecated flat layout.
|
||||||
|
"""
|
||||||
(tmp_path / "src").mkdir()
|
(tmp_path / "src").mkdir()
|
||||||
(tmp_path / "src" / "test_repo").mkdir()
|
(tmp_path / "src" / "test_repo").mkdir()
|
||||||
(tmp_path / "src" / "test_repo" / "__init__.py").write_text("")
|
(tmp_path / "src" / "test_repo" / "__init__.py").write_text("")
|
||||||
_write_full_pyproject(tmp_path, src_pkg_exists=False, hatch_packages_override=["src"])
|
_write_full_pyproject(tmp_path, src_pkg_exists=False, hatch_packages_override=["src"])
|
||||||
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
group = ps.check_pyproject(ps.ProjectType.BACKEND)
|
||||||
assert any(
|
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
|
for c in group.checks
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_check_pyproject_check2_hatch_wheel_warn_app_layout(tmp_path):
|
def test_check_pyproject_check2_hatch_wheel_warn_app_layout(tmp_path):
|
||||||
"""src/ exists but no src/<pkg>/ → WARN (app-layout for backend)."""
|
"""src/ exists but no src/<pkg>/ → WARN (deprecated flat layout, issue #241)."""
|
||||||
(tmp_path / "src").mkdir()
|
(tmp_path / "src").mkdir()
|
||||||
(tmp_path / "src" / "api").mkdir()
|
(tmp_path / "src" / "api").mkdir()
|
||||||
_write_full_pyproject(tmp_path, src_pkg_exists=False)
|
_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(
|
assert any(
|
||||||
c.name == "[tool.hatch.build.targets.wheel]"
|
c.name == "[tool.hatch.build.targets.wheel]"
|
||||||
and c.status == ps.CheckStatus.WARN
|
and c.status == ps.CheckStatus.WARN
|
||||||
and "app-layout" in c.detail
|
and "deprecated flat layout" in c.detail
|
||||||
for c in group.checks
|
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):
|
def test_check_structure_flat_warn_for_backend(tmp_path):
|
||||||
"""Backend with src/api/ (app-layout) → no flat-layout WARN."""
|
"""Backend with flat src/api/ (no nested package) → WARN flat src/ layout.
|
||||||
|
|
||||||
|
Inverted by issue #241: nested ``src/<package>/`` 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/<pkg>/api/v1 → OK (no flat-layout WARN)."""
|
||||||
_make_backend_repo(tmp_path)
|
_make_backend_repo(tmp_path)
|
||||||
group = ps.check_structure(ps.ProjectType.BACKEND)
|
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)
|
assert not any(c.name == "flat src/ layout" for c in group.checks)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue