Skip to content

Commit 01ae6d5

Browse files
authored
Merge pull request #55 from bnbong/dev
[FEAT] Support architecture presets in dynamic project generation
2 parents f26b525 + 83f0e75 commit 01ae6d5

10 files changed

Lines changed: 885 additions & 31 deletions

File tree

docs/en/reference/faq.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,7 +182,10 @@ Interactive mode walks you through these steps in order:
182182

183183
1. **Project information** — name, author, email, description.
184184
2. **Architecture preset** — picks the project layout. The recommended
185-
default is `domain-starter`; press Enter to accept it.
185+
default is `domain-starter`; press Enter to accept it. See the
186+
[preset / feature matrix](preset-feature-matrix.md) for the exact
187+
layout each preset produces and which feature combinations require
188+
manual wiring.
186189
3. **Feature selections** — database, authentication, background tasks,
187190
caching, monitoring, testing, utilities, deployment.
188191
4. **Package manager and custom packages** — pip / uv / pdm / poetry,
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
# Architecture preset / feature matrix
2+
3+
Interactive `fastkit init --interactive` asks for an **architecture preset**
4+
([issue #44](https://github.com/bnbong/FastAPI-fastkit/issues/44)) before
5+
collecting feature selections. The preset shapes the generated project's
6+
layout — different presets ship a different base template and put generated
7+
config files in different locations so they sit next to the existing
8+
structure rather than in a parallel `src/config/` tree.
9+
10+
This page is the source of truth for what each preset does, where files
11+
land, and which feature combinations require manual wiring.
12+
13+
## Preset → base template
14+
15+
| Preset | Base template | Description |
16+
|---|---|---|
17+
| `minimal` | `fastapi-empty` | Smallest viable FastAPI app — placeholder `main.py` is regenerated from your feature selections. |
18+
| `single-module` | `fastapi-single-module` | Single-file FastAPI app — `main.py` is regenerated. |
19+
| `classic-layered` | `fastapi-default` | Layered split (`api/routes`, `crud`, `schemas`, `core`). Shipped `main.py` is preserved. |
20+
| `domain-starter` | `fastapi-domain-starter` | Domain-oriented (`src/app/domains/<concept>/`). Shipped `main.py` is preserved. **Recommended default.** |
21+
22+
## Generated file locations
23+
24+
| Preset | `main.py` overlay | Database config target | Auth config target |
25+
|---|---|---|---|
26+
| `minimal` | regenerated at `src/main.py` | `src/config/database.py` | `src/config/auth.py` |
27+
| `single-module` | regenerated at `src/main.py` | `src/config/database.py` | `src/config/auth.py` |
28+
| `classic-layered` | preserved (template-shipped) | `src/core/database.py` | `src/core/auth.py` |
29+
| `domain-starter` | preserved (template-shipped) | `src/app/core/database.py` | `src/app/core/auth.py` |
30+
31+
## Database / auth feature support per preset
32+
33+
These features are supported across **every** preset — the package install
34+
always succeeds; the difference is whether the dynamic `main.py` overlay
35+
also wires them up automatically.
36+
37+
| Feature | `minimal` / `single-module` | `classic-layered` / `domain-starter` |
38+
|---|---|---|
39+
| **Database** (PostgreSQL, MySQL, SQLite, MongoDB) | Generates the config module **and** stubs `await init_db()` calls in the regenerated `main.py`. | Generates the config module at the preset's path. The shipped `main.py` is **preserved**, so wire `get_db()` into routers manually. |
40+
| **Authentication** (JWT, FastAPI-Users, OAuth2, Session-based) | Generates the auth config module. JWT also imports `HTTPBearer` in the regenerated `main.py`. | Generates the auth config module at the preset's path. No imports added to `main.py` — wire dependencies manually. |
41+
| **Background tasks** (Celery, Dramatiq) | Packages installed; no main.py overlay today. | Same. |
42+
| **Caching** (Redis) | Packages installed; no main.py overlay today. | Same. |
43+
| **CORS** (utility) | `CORSMiddleware` added to the regenerated `main.py` with `allow_origins=['*']`. | **Already wired** in the shipped `main.py` (conditional on `settings.all_cors_origins`). Activate by setting `BACKEND_CORS_ORIGINS` in `.env` — no code edits required. |
44+
| **Testing** (Basic / Coverage / Advanced) | `pytest.ini` is generated at the project root. | Same. |
45+
| **Deployment** (Docker, docker-compose) | `Dockerfile` and/or `docker-compose.yml` written at the project root. | Same. |
46+
47+
## When you'll see a "Preset compatibility" warning
48+
49+
For presets that **preserve the shipped `main.py`** (`classic-layered`,
50+
`domain-starter`), some feature selections won't be auto-wired into the
51+
app. The CLI surfaces a one-shot warning at the end of generation listing
52+
which selections need manual wiring:
53+
54+
| Selected feature | Triggers a warning under `classic-layered` / `domain-starter`? |
55+
|---|---|
56+
| `CORS` (utility) | ❌ — already wired in the shipped `main.py`. Just populate `BACKEND_CORS_ORIGINS` in `.env`. |
57+
| `Rate-Limiting` (utility) | ✅ — `slowapi` limiter setup is not added |
58+
| `Prometheus` (monitoring) | ✅ — `Instrumentator().instrument(app)` is not called |
59+
| Any database / auth selection | ⚠️ — config files are generated, but you must `Depends()` them into your routers |
60+
61+
For `minimal` and `single-module` presets the dynamic `main.py` overlay
62+
handles CORS, rate-limiting, and Prometheus instrumentation automatically;
63+
no warnings fire.
64+
65+
## Unsupported combinations (stay safe)
66+
67+
The strategist deliberately **does not** attempt to splice generated code
68+
into a template-shipped `main.py`. Doing so would risk producing broken
69+
imports or duplicating routers. The contract is:
70+
71+
- Selected packages are always installed (so `pip freeze` matches the
72+
user's intent).
73+
- Generated config modules always land at the preset-appropriate path.
74+
- For preserve-main presets, the user is told which selections still need
75+
manual wiring instead of getting silently broken code.
76+
77+
If you need full auto-wiring of every feature, pick `minimal` or
78+
`single-module` — they regenerate `main.py` from feature flags.

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ nav:
140140
- Translation Guide: contributing/translation-guide.md
141141
- Reference:
142142
- FAQ: reference/faq.md
143+
- Architecture Preset Matrix: reference/preset-feature-matrix.md
143144
- Template Quality Assurance: reference/template-quality-assurance.md
144145
- Changelog: changelog.md
145146

src/fastapi_fastkit/backend/project_builder/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,11 @@
1010
# --------------------------------------------------------------------------
1111
from .config_generator import DynamicConfigGenerator
1212
from .dependency_collector import DependencyCollector
13+
from .preset_layout import PresetLayoutStrategist, PresetProfile
1314

1415
__all__ = [
1516
"DependencyCollector",
1617
"DynamicConfigGenerator",
18+
"PresetLayoutStrategist",
19+
"PresetProfile",
1720
]

src/fastapi_fastkit/backend/project_builder/config_generator.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -447,12 +447,20 @@ def _generate_fastapi_users_config(self) -> str:
447447

448448
return "\n".join(content)
449449

450-
def generate_docker_files(self) -> None:
451-
"""Generate Dockerfile and docker-compose.yml."""
450+
def generate_docker_files(self, app_module: str = "src.main:app") -> None:
451+
"""Generate Dockerfile and docker-compose.yml.
452+
453+
The ``app_module`` is the ``module:attr`` string baked into the
454+
Dockerfile's ``CMD`` (and into docker-compose's `command` if the
455+
compose generator ever needs it). Architecture presets that put
456+
the FastAPI app at a non-default location (e.g. ``domain-starter``
457+
ships ``src/app/main.py``) must pass the matching dotted path so
458+
the generated container actually starts.
459+
"""
452460
deployment = self.config.get("deployment", [])
453461

454462
if "Docker" in deployment:
455-
dockerfile_content = self._generate_dockerfile()
463+
dockerfile_content = self._generate_dockerfile(app_module=app_module)
456464
dockerfile_path = self.project_dir / "Dockerfile"
457465
with open(dockerfile_path, "w") as f:
458466
f.write(dockerfile_content)
@@ -463,8 +471,8 @@ def generate_docker_files(self) -> None:
463471
with open(compose_path, "w") as f:
464472
f.write(compose_content)
465473

466-
def _generate_dockerfile(self) -> str:
467-
"""Generate Dockerfile content."""
474+
def _generate_dockerfile(self, app_module: str = "src.main:app") -> str:
475+
"""Generate Dockerfile content with a layout-aware uvicorn target."""
468476
content = []
469477
content.append(
470478
"# --------------------------------------------------------------------------"
@@ -487,7 +495,7 @@ def _generate_dockerfile(self) -> str:
487495
content.append("")
488496
content.append("# Run application")
489497
content.append(
490-
'CMD ["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000"]'
498+
f'CMD ["uvicorn", "{app_module}", "--host", "0.0.0.0", "--port", "8000"]'
491499
)
492500
content.append("")
493501

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
# --------------------------------------------------------------------------
2+
# Architecture-preset layout strategy for interactive project generation.
3+
#
4+
# Maps each architecture preset (issue #44) to the actual generation
5+
# decisions interactive ``init`` makes:
6+
#
7+
# - which template ships as the base scaffold,
8+
# - whether the dynamic ``main.py`` overlay should overwrite the shipped one,
9+
# - where database / auth config files should land so they sit next to the
10+
# template's existing structure rather than in a parallel ``src/config``,
11+
# - and which feature combinations need a "you must wire this up manually"
12+
# warning because the dynamic ``main.py`` overlay isn't applied.
13+
#
14+
# Keeping every preset's layout knowledge in one place lets the CLI flow stay
15+
# linear ("ask the strategist where to write the file") instead of growing a
16+
# branching maze of preset-specific if/else blocks.
17+
#
18+
# @author bnbong bbbong9@gmail.com
19+
# --------------------------------------------------------------------------
20+
from __future__ import annotations
21+
22+
from dataclasses import dataclass, field
23+
from pathlib import Path
24+
from typing import Any, Dict, List, Tuple
25+
26+
# Canonical preset id used when a caller doesn't supply one. Picked to
27+
# preserve pre-#45 behaviour: interactive ``init`` historically deployed
28+
# ``fastapi-empty`` and regenerated ``src/main.py`` from feature flags.
29+
_FALLBACK_PRESET_ID: str = "minimal"
30+
31+
32+
@dataclass(frozen=True)
33+
class PresetProfile:
34+
"""Per-preset generation decisions. Treat as a value object."""
35+
36+
preset_id: str
37+
base_template: str
38+
regenerate_main: bool
39+
main_py_relpath: str
40+
db_config_relpath: str
41+
auth_config_relpath: str
42+
# Hint shown when ``regenerate_main`` is False and the user picked a
43+
# feature whose dynamic main.py overlay won't run. Empty string means
44+
# "no special note for this preset".
45+
manual_wiring_note: str = ""
46+
extra_warning_targets: Tuple[str, ...] = field(default_factory=tuple)
47+
48+
49+
_PRESET_PROFILES: Dict[str, PresetProfile] = {
50+
"minimal": PresetProfile(
51+
preset_id="minimal",
52+
base_template="fastapi-empty",
53+
regenerate_main=True,
54+
main_py_relpath="src/main.py",
55+
db_config_relpath="src/config/database.py",
56+
auth_config_relpath="src/config/auth.py",
57+
),
58+
"single-module": PresetProfile(
59+
preset_id="single-module",
60+
base_template="fastapi-single-module",
61+
regenerate_main=True,
62+
main_py_relpath="src/main.py",
63+
db_config_relpath="src/config/database.py",
64+
auth_config_relpath="src/config/auth.py",
65+
),
66+
"classic-layered": PresetProfile(
67+
preset_id="classic-layered",
68+
base_template="fastapi-default",
69+
regenerate_main=False,
70+
main_py_relpath="src/main.py",
71+
db_config_relpath="src/core/database.py",
72+
auth_config_relpath="src/core/auth.py",
73+
# CORS is intentionally NOT in this list: fastapi-default's shipped
74+
# main.py already imports CORSMiddleware and adds it conditionally
75+
# on settings.all_cors_origins, so the user only has to populate
76+
# BACKEND_CORS_ORIGINS in .env — no code edits needed.
77+
manual_wiring_note=(
78+
"fastapi-default's shipped src/main.py is preserved. The "
79+
"selections below need manual wiring there (CORS is already "
80+
"wired — set BACKEND_CORS_ORIGINS in .env to activate it)."
81+
),
82+
extra_warning_targets=("Rate-Limiting", "Prometheus"),
83+
),
84+
"domain-starter": PresetProfile(
85+
preset_id="domain-starter",
86+
base_template="fastapi-domain-starter",
87+
regenerate_main=False,
88+
main_py_relpath="src/app/main.py",
89+
db_config_relpath="src/app/core/database.py",
90+
auth_config_relpath="src/app/core/auth.py",
91+
manual_wiring_note=(
92+
"fastapi-domain-starter's shipped src/app/main.py is preserved. "
93+
"The selections below need manual wiring there (CORS is already "
94+
"wired — set BACKEND_CORS_ORIGINS in .env to activate it)."
95+
),
96+
extra_warning_targets=("Rate-Limiting", "Prometheus"),
97+
),
98+
}
99+
100+
101+
class PresetLayoutStrategist:
102+
"""Single source of truth for preset → generation-layout decisions."""
103+
104+
def __init__(self, preset_id: str | None) -> None:
105+
# Empty / None / unknown ids fall back to ``minimal`` so older callers
106+
# that pre-date the architecture-preset prompt keep working.
107+
canonical = (preset_id or _FALLBACK_PRESET_ID).strip()
108+
self.profile: PresetProfile = _PRESET_PROFILES.get(
109+
canonical, _PRESET_PROFILES[_FALLBACK_PRESET_ID]
110+
)
111+
112+
@classmethod
113+
def supported_presets(cls) -> List[str]:
114+
"""Return the ordered list of preset ids the strategist understands."""
115+
return list(_PRESET_PROFILES.keys())
116+
117+
@property
118+
def preset_id(self) -> str:
119+
return self.profile.preset_id
120+
121+
@property
122+
def base_template(self) -> str:
123+
return self.profile.base_template
124+
125+
@property
126+
def should_regenerate_main(self) -> bool:
127+
return self.profile.regenerate_main
128+
129+
def main_py_target(self, project_dir: str) -> Path:
130+
"""Absolute path where the dynamic main.py overlay should land."""
131+
return Path(project_dir) / self.profile.main_py_relpath
132+
133+
@property
134+
def app_module(self) -> str:
135+
"""Return the ``module:attr`` string uvicorn / Docker should target.
136+
137+
Derived from ``main_py_relpath`` so docker generation, runserver,
138+
and any future container-orchestration code all agree on the
139+
entrypoint a given preset produces.
140+
"""
141+
# Strip the trailing ``.py`` and convert path separators to dots.
142+
relpath = self.profile.main_py_relpath
143+
if relpath.endswith(".py"):
144+
relpath = relpath[: -len(".py")]
145+
module_part = relpath.replace("/", ".").replace("\\", ".")
146+
return f"{module_part}:app"
147+
148+
def db_config_target(self, project_dir: str) -> Path:
149+
"""Absolute path for the generated database config module."""
150+
return Path(project_dir) / self.profile.db_config_relpath
151+
152+
def auth_config_target(self, project_dir: str) -> Path:
153+
"""Absolute path for the generated authentication config module."""
154+
return Path(project_dir) / self.profile.auth_config_relpath
155+
156+
def compatibility_warnings(self, config: Dict[str, Any]) -> List[str]:
157+
"""Return user-facing warnings for unsupported preset/feature mixes.
158+
159+
The dynamic ``main.py`` overlay (CORS middleware wiring, Prometheus
160+
instrumentation, rate-limit hookup) only runs for presets that
161+
regenerate ``main.py``. For the other presets we keep the
162+
template-shipped ``main.py`` intact and surface a single warning
163+
listing the affected features so users know to wire them up
164+
themselves rather than assuming the package install was enough.
165+
"""
166+
if self.profile.regenerate_main:
167+
return []
168+
169+
affected = self._affected_overlay_targets(config)
170+
if not affected:
171+
return []
172+
173+
warnings: List[str] = []
174+
if self.profile.manual_wiring_note:
175+
warnings.append(self.profile.manual_wiring_note)
176+
warnings.append(
177+
"Affected selections (packages installed, but no dynamic main.py "
178+
"edits applied for the '"
179+
+ self.profile.preset_id
180+
+ "' preset): "
181+
+ ", ".join(affected)
182+
)
183+
return warnings
184+
185+
def _affected_overlay_targets(self, config: Dict[str, Any]) -> List[str]:
186+
"""Detect which of the user's selections rely on main.py overlay."""
187+
triggered: List[str] = []
188+
utilities = set(config.get("utilities") or [])
189+
190+
for target in self.profile.extra_warning_targets:
191+
if target in {"CORS", "Rate-Limiting"}:
192+
if target in utilities:
193+
triggered.append(target)
194+
elif target == "Prometheus":
195+
if config.get("monitoring") == "Prometheus":
196+
triggered.append(target)
197+
return triggered
198+
199+
200+
__all__ = [
201+
"PresetLayoutStrategist",
202+
"PresetProfile",
203+
]

0 commit comments

Comments
 (0)