symfonia-ai-tools 1.7.1 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -73,6 +73,7 @@ Instead of choosing a single stack, you pick any combination of instruction & sk
73
73
  | **Vitest** | 1 instruction | Yes |
74
74
  | **Storybook** | 1 instruction | No |
75
75
  | **Laravel + PHP** | 4 instructions, 7 skills | No |
76
+ | **Python FastAPI** | 4 instructions, 7 skills | No |
76
77
  | **Playwright E2E** | 1 instruction, 3 skills | No |
77
78
  | **Docker** | 1 instruction | No |
78
79
 
@@ -155,6 +156,15 @@ When AI opens/edits a file matching `applyTo`, it automatically loads those inst
155
156
  | `api-resource.instructions.md` | `**/*Resource*.php, **/*Request*.php` | Resources with whenLoaded(), Form Requests, Policies |
156
157
  | `testing.instructions.md` | `**/*Test*.php, **/tests/**` | PHPUnit, data providers, factories, Laravel fakes |
157
158
 
159
+ ### Python FastAPI (4 instructions)
160
+
161
+ | Instruction | applyTo | What it teaches AI |
162
+ |-------------|---------|-------------------|
163
+ | `api-schema.instructions.md` | `**/schemas.py, **/*schema*.py, **/*request*.py, **/*response*.py` | Pydantic v2 schemas, StrEnum status fields, `PaginatedResponse[T]`, frozen request models, `ErrorResponse` |
164
+ | `module.instructions.md` | `**/modules/**/router.py, **/modules/**/dependencies.py, **/modules/**/domain/**, **/modules/**/infrastructure/**` | Module structure, FastAPI router pattern, SQLAlchemy 2.0 ORM models, `lifespan=` setup, DI wiring |
165
+ | `service-repository.instructions.md` | `**/domain/services.py, **/infrastructure/repositories/**, **/domain/entities.py, **/domain/value_objects.py` | CQRS repos, domain entities, EventPublisher pattern, layer isolation rules |
166
+ | `testing.instructions.md` | `**/tests/**, **/*test*.py, **/conftest.py` | pytest-asyncio conftest, `async_sessionmaker`, rollback pattern, `AsyncClient` |
167
+
158
168
  ### Playwright (1 instruction)
159
169
 
160
170
  | Instruction | applyTo | What it teaches AI |
@@ -267,6 +277,18 @@ All skills have the `smf-` prefix (Symfonia). The configurator lets you choose w
267
277
  | `smf-testing-unit` | Unit tests | Isolated tests without DB, PHPUnit mocks, data providers |
268
278
  | `smf-testing-manual` | Manual HTTP tests | JetBrains HTTP Client — `.http` files for interactive API testing |
269
279
 
280
+ ### Python FastAPI skills (7 skills)
281
+
282
+ | Skill | Purpose | When to use |
283
+ |-------|---------|-------------|
284
+ | `smf-new-module` | New module from scratch | router → schemas → ORM model → CQRS repos → service → migration → tests |
285
+ | `smf-new-endpoint` | New endpoint in existing module | request schema → route → service method → response schema → tests |
286
+ | `smf-migration` | Alembic migration | autogenerate → review → upgrade → verify; async env.py setup |
287
+ | `smf-background-task` | Celery background task | task def (sync) → `SyncSessionLocal` → dispatch from router → beat schedule → tests |
288
+ | `smf-testing-integration` | Integration tests (HTTP) | `AsyncClient` → real DB via rollback conftest → router assertions |
289
+ | `smf-testing-unit` | Unit tests (service) | mock repos → service logic → no DB needed |
290
+ | `smf-testing-manual` | Manual HTTP tests | JetBrains HTTP Client `.http` files for interactive API testing |
291
+
270
292
  ### Playwright skills (3 skills)
271
293
 
272
294
  | Skill | Purpose | When to use |
@@ -524,6 +546,7 @@ templates/
524
546
  ├── vitest/ # 1 instr.
525
547
  ├── storybook/ # 1 instr.
526
548
  ├── laravel/ # 4 instr. · 7 skills
549
+ ├── python-fastapi/ # 4 instr. · 7 skills
527
550
  ├── playwright/ # 1 instr. · 3 skills
528
551
  └── docker/ # 1 instr.
529
552
  ```
package/lib/questions.mjs CHANGED
@@ -156,9 +156,10 @@ export async function askQuestions(packsDir) {
156
156
 
157
157
  const hasVue = answers.packs.includes('vue3');
158
158
  const hasLaravel = answers.packs.includes('laravel');
159
- answers.testCommand = await ask(t('q.cmd.test'), hasVue ? 'npm run test' : hasLaravel ? 'php artisan test' : 'npm test');
160
- answers.buildCommand = await ask(t('q.cmd.build'), hasVue ? 'npm run build' : hasLaravel ? 'composer build' : 'npm run build');
161
- answers.lintCommand = await ask(t('q.cmd.lint'), hasVue ? 'npm run lint' : hasLaravel ? 'php artisan pint' : 'npm run lint');
159
+ const hasPythonFastapi = answers.packs.includes('python-fastapi');
160
+ answers.testCommand = await ask(t('q.cmd.test'), hasVue ? 'npm run test' : hasLaravel ? 'php artisan test' : hasPythonFastapi ? 'pytest' : 'npm test');
161
+ answers.buildCommand = await ask(t('q.cmd.build'), hasVue ? 'npm run build' : hasLaravel ? 'composer build' : hasPythonFastapi ? 'docker build .' : 'npm run build');
162
+ answers.lintCommand = await ask(t('q.cmd.lint'), hasVue ? 'npm run lint' : hasLaravel ? 'php artisan pint' : hasPythonFastapi ? 'ruff check . && mypy .' : 'npm run lint');
162
163
 
163
164
  // --- CI ---
164
165
  section(t('q.section.ci'));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "symfonia-ai-tools",
3
- "version": "1.7.1",
3
+ "version": "1.8.0",
4
4
  "description": "AI tooling setup for your project - Claude Code, GitHub Copilot, Cursor, Gemini, Junie, GSD",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,177 @@
1
+ ---
2
+ applyTo: "**/schemas.py,**/*schema*.py,**/*request*.py,**/*response*.py"
3
+ ---
4
+
5
+ # API Schemas, Request & Response Models — Instructions
6
+
7
+ ## Pydantic v2 Schemas
8
+
9
+ Never return ORM models directly from endpoints. All input/output passes through Pydantic schemas.
10
+
11
+ ```python
12
+ from __future__ import annotations
13
+
14
+ from datetime import datetime
15
+ from uuid import UUID
16
+
17
+ from pydantic import BaseModel, ConfigDict, Field
18
+
19
+
20
+ class FeatureResponse(BaseModel):
21
+ model_config = ConfigDict(from_attributes=True)
22
+
23
+ id: UUID
24
+ name: str
25
+ description: str | None
26
+ status: FeatureStatus # use the StrEnum — not bare `str`
27
+ created_at: datetime
28
+
29
+ # Nested relations — use None default (loaded on demand)
30
+ author: UserResponse | None = None
31
+ category: CategoryResponse | None = None
32
+ ```
33
+
34
+ ### Request Schemas (Input Validation)
35
+
36
+ Request schemas are **read-only once validated** — add `frozen=True` to catch accidental mutation:
37
+
38
+ ```python
39
+ from __future__ import annotations
40
+
41
+ from pydantic import BaseModel, ConfigDict, Field, field_validator
42
+ from uuid import UUID
43
+
44
+
45
+ class StoreFeatureRequest(BaseModel):
46
+ model_config = ConfigDict(frozen=True)
47
+
48
+ name: str = Field(min_length=2, max_length=255)
49
+ description: str | None = Field(default=None, max_length=5000)
50
+ category_id: UUID | None = None
51
+ status: FeatureStatus
52
+ tags: list[str] = Field(default_factory=list)
53
+
54
+ @field_validator("tags")
55
+ @classmethod
56
+ def validate_tags(cls, v: list[str]) -> list[str]:
57
+ if any(len(tag) > 50 for tag in v):
58
+ raise ValueError("Each tag must be at most 50 characters")
59
+ return v
60
+
61
+
62
+ class UpdateFeatureRequest(BaseModel):
63
+ model_config = ConfigDict(frozen=True)
64
+
65
+ name: str | None = Field(default=None, min_length=2, max_length=255)
66
+ description: str | None = None
67
+ status: FeatureStatus | None = None
68
+ ```
69
+
70
+ ### List / Index Request with Filtering and Sorting
71
+
72
+ ```python
73
+ from __future__ import annotations
74
+
75
+ from typing import Literal
76
+ from pydantic import BaseModel, Field
77
+
78
+
79
+ class IndexFeatureRequest(BaseModel):
80
+ page: int = Field(default=1, ge=1)
81
+ limit: int = Field(default=20, ge=1, le=100)
82
+ status: FeatureStatus | None = None
83
+ sort_by: Literal["created_at", "name", "status"] = "created_at"
84
+ sort_dir: Literal["asc", "desc"] = "desc"
85
+ ```
86
+
87
+ ### Paginated Collection Response
88
+
89
+ `PaginatedResponse` and `PaginationMeta` are **shared types** — define them once in `app/core/schemas.py`, never per-module:
90
+
91
+ ```python
92
+ # app/core/schemas.py
93
+ from __future__ import annotations
94
+
95
+ from typing import Generic, TypeVar
96
+ from pydantic import BaseModel
97
+
98
+ T = TypeVar("T")
99
+
100
+
101
+ class PaginationMeta(BaseModel):
102
+ total: int
103
+ per_page: int
104
+ current_page: int
105
+ last_page: int
106
+
107
+
108
+ class PaginatedResponse(BaseModel, Generic[T]):
109
+ data: list[T]
110
+ meta: PaginationMeta
111
+ ```
112
+
113
+ Import from `app.core.schemas` in every module that needs it.
114
+
115
+ ## Error Responses
116
+
117
+ Standardize the error body shape. Define `ErrorResponse` in `app/core/schemas.py`:
118
+
119
+ ```python
120
+ # app/core/schemas.py
121
+ class ErrorResponse(BaseModel):
122
+ code: str # machine-readable code: "FEATURE_NOT_FOUND"
123
+ message: str # human-readable description
124
+ field: str | None = None # which field caused the error (optional)
125
+ ```
126
+
127
+ Raise via `HTTPException` — the exception handler converts it:
128
+
129
+ ```python
130
+ from fastapi import HTTPException, status
131
+
132
+ # In service (expected domain errors):
133
+ raise HTTPException(
134
+ status_code=status.HTTP_404_NOT_FOUND,
135
+ detail={"code": "FEATURE_NOT_FOUND", "message": "Feature not found"},
136
+ )
137
+ ```
138
+
139
+ Register a handler in `app/main.py` to enforce consistent shape:
140
+
141
+ ```python
142
+ from fastapi import Request
143
+ from fastapi.responses import JSONResponse
144
+
145
+ @app.exception_handler(HTTPException)
146
+ async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse:
147
+ if isinstance(exc.detail, dict):
148
+ body = exc.detail
149
+ else:
150
+ body = {"code": "HTTP_ERROR", "message": str(exc.detail)}
151
+ return JSONResponse(status_code=exc.status_code, content=body)
152
+ ```
153
+
154
+ ## Enums
155
+
156
+ ```python
157
+ from enum import StrEnum
158
+
159
+
160
+ class FeatureStatus(StrEnum):
161
+ DRAFT = "draft"
162
+ ACTIVE = "active"
163
+ ARCHIVED = "archived"
164
+ ```
165
+
166
+ Use `StrEnum` (Python 3.11+) — auto-serializes to string in JSON.
167
+
168
+ ## Rules
169
+
170
+ - `from_attributes=True` on every response schema that reads from ORM
171
+ - Never expose internal IDs as integers — use UUID
172
+ - Use `Field(...)` for constraints, not just annotations
173
+ - `StrEnum` for all enums — clean JSON serialization, use in both request and response schemas
174
+ - Nested relations are always `| None = None` — populate only when loaded
175
+ - `frozen=True` on all request/input schemas — prevents accidental mutation after validation
176
+ - `PaginatedResponse[T]` and `PaginationMeta` live in `app/core/schemas.py` — import, never redefine
177
+ - `ErrorResponse` body has `code` (machine) and `message` (human) — register a global exception handler
@@ -0,0 +1,211 @@
1
+ ---
2
+ applyTo: "**/modules/**/router.py,**/modules/**/dependencies.py,**/modules/**/domain/**,**/modules/**/infrastructure/**"
3
+ ---
4
+
5
+ # Modular Architecture — Instructions
6
+
7
+ ## Module Structure
8
+
9
+ Each business domain has its own directory under `modules/`:
10
+
11
+ ```
12
+ modules/feature/
13
+ ├── __init__.py
14
+ ├── router.py # FastAPI APIRouter — thin, no logic
15
+ ├── dependencies.py # Depends() factories for DI
16
+ ├── domain/
17
+ │ ├── __init__.py
18
+ │ ├── entities.py # Domain entities (pure Python dataclasses)
19
+ │ ├── schemas.py # Pydantic v2 request/response schemas
20
+ │ ├── services.py # Business logic
21
+ │ ├── events.py # [optional] Domain events (dataclasses)
22
+ │ └── value_objects.py # [optional] Value objects (frozen dataclasses)
23
+ ├── infrastructure/
24
+ │ ├── __init__.py
25
+ │ ├── orm.py # SQLAlchemy ORM mapped classes
26
+ │ ├── repositories/
27
+ │ │ ├── __init__.py
28
+ │ │ ├── base.py # Abstract base classes (interfaces)
29
+ │ │ ├── command_repository.py # SQLAlchemy write implementation
30
+ │ │ └── query_repository.py # SQLAlchemy read implementation
31
+ │ └── tasks.py # [optional] Celery tasks for this module
32
+ └── tests/
33
+ ├── __init__.py
34
+ ├── conftest.py # Module-level fixtures
35
+ ├── integration/
36
+ │ ├── __init__.py
37
+ │ └── test_feature_router.py
38
+ └── unit/
39
+ ├── __init__.py
40
+ └── test_feature_service.py
41
+ ```
42
+
43
+ > **Optional files** (`events.py`, `value_objects.py`, `tasks.py`) — create only when needed. Do not scaffold files that will stay empty.
44
+
45
+ ## Router (`router.py`)
46
+
47
+ ```python
48
+ from __future__ import annotations
49
+
50
+ from uuid import UUID
51
+
52
+ from fastapi import APIRouter, Depends, status
53
+
54
+ from app.core.auth import get_current_user
55
+ from modules.feature.dependencies import get_feature_service
56
+ from modules.feature.domain.schemas import (
57
+ FeatureResponse,
58
+ IndexFeatureRequest,
59
+ PaginatedResponse,
60
+ StoreFeatureRequest,
61
+ UpdateFeatureRequest,
62
+ )
63
+ from modules.feature.domain.services import FeatureService
64
+
65
+ router = APIRouter(prefix="/features", tags=["features"])
66
+
67
+
68
+ @router.get("", response_model=PaginatedResponse[FeatureResponse])
69
+ async def index(
70
+ params: IndexFeatureRequest = Depends(),
71
+ current_user=Depends(get_current_user),
72
+ service: FeatureService = Depends(get_feature_service),
73
+ ) -> PaginatedResponse[FeatureResponse]:
74
+ return await service.list_for_user(current_user.id, params)
75
+
76
+
77
+ @router.post("", response_model=FeatureResponse, status_code=status.HTTP_201_CREATED)
78
+ async def store(
79
+ body: StoreFeatureRequest,
80
+ current_user=Depends(get_current_user),
81
+ service: FeatureService = Depends(get_feature_service),
82
+ ) -> FeatureResponse:
83
+ return await service.create(current_user.id, body)
84
+
85
+
86
+ @router.get("/{feature_id}", response_model=FeatureResponse)
87
+ async def show(
88
+ feature_id: UUID,
89
+ current_user=Depends(get_current_user),
90
+ service: FeatureService = Depends(get_feature_service),
91
+ ) -> FeatureResponse:
92
+ return await service.get_or_404(feature_id, current_user.id)
93
+
94
+
95
+ @router.patch("/{feature_id}", response_model=FeatureResponse)
96
+ async def update(
97
+ feature_id: UUID,
98
+ body: UpdateFeatureRequest,
99
+ current_user=Depends(get_current_user),
100
+ service: FeatureService = Depends(get_feature_service),
101
+ ) -> FeatureResponse:
102
+ return await service.update(feature_id, current_user.id, body)
103
+
104
+
105
+ @router.delete("/{feature_id}", status_code=status.HTTP_204_NO_CONTENT)
106
+ async def destroy(
107
+ feature_id: UUID,
108
+ current_user=Depends(get_current_user),
109
+ service: FeatureService = Depends(get_feature_service),
110
+ ) -> None:
111
+ await service.delete(feature_id, current_user.id)
112
+ ```
113
+
114
+ ## Dependencies (`dependencies.py`)
115
+
116
+ ```python
117
+ from __future__ import annotations
118
+
119
+ from fastapi import Depends
120
+ from sqlalchemy.ext.asyncio import AsyncSession
121
+
122
+ from app.core.database import get_db
123
+ from modules.feature.domain.services import FeatureService
124
+ from modules.feature.infrastructure.repositories.command_repository import (
125
+ SqlAlchemyFeatureCommandRepository,
126
+ )
127
+ from modules.feature.infrastructure.repositories.query_repository import (
128
+ SqlAlchemyFeatureQueryRepository,
129
+ )
130
+
131
+
132
+ def get_feature_service(
133
+ db: AsyncSession = Depends(get_db),
134
+ ) -> FeatureService:
135
+ return FeatureService(
136
+ command_repo=SqlAlchemyFeatureCommandRepository(db),
137
+ query_repo=SqlAlchemyFeatureQueryRepository(db),
138
+ )
139
+ ```
140
+
141
+ ## Router Registration (`app/main.py`)
142
+
143
+ ```python
144
+ from contextlib import asynccontextmanager
145
+ from fastapi import FastAPI
146
+ from app.core.config import settings
147
+ from modules.feature.router import router as feature_router
148
+
149
+
150
+ @asynccontextmanager
151
+ async def lifespan(app: FastAPI):
152
+ # startup
153
+ yield
154
+ # shutdown
155
+
156
+ app = FastAPI(lifespan=lifespan)
157
+ app.include_router(feature_router, prefix=f"/api/{settings.API_VERSION}")
158
+ ```
159
+
160
+ > Use `lifespan=` context manager (FastAPI 0.95+). The deprecated `@app.on_event("startup")` pattern must not be used.
161
+
162
+ ## ORM Model (`infrastructure/orm.py`)
163
+
164
+ ```python
165
+ from __future__ import annotations
166
+
167
+ import uuid
168
+ from datetime import datetime
169
+
170
+ from sqlalchemy import UUID as SA_UUID
171
+ from sqlalchemy import DateTime, Enum, ForeignKey, String, Text, func
172
+ from sqlalchemy.orm import Mapped, mapped_column, relationship
173
+
174
+ from app.core.database import Base
175
+ from modules.feature.domain.entities import FeatureStatus
176
+
177
+
178
+ class FeatureORM(Base):
179
+ __tablename__ = "features"
180
+
181
+ id: Mapped[uuid.UUID] = mapped_column(
182
+ SA_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
183
+ )
184
+ name: Mapped[str] = mapped_column(String(255), nullable=False)
185
+ description: Mapped[str | None] = mapped_column(Text, nullable=True)
186
+ status: Mapped[FeatureStatus] = mapped_column(
187
+ Enum(FeatureStatus), nullable=False, default=FeatureStatus.DRAFT
188
+ )
189
+ user_id: Mapped[uuid.UUID] = mapped_column(
190
+ SA_UUID(as_uuid=True), ForeignKey("users.id"), nullable=False, index=True
191
+ )
192
+ created_at: Mapped[datetime] = mapped_column(
193
+ DateTime(timezone=True), server_default=func.now(), nullable=False
194
+ )
195
+ deleted_at: Mapped[datetime | None] = mapped_column(
196
+ DateTime(timezone=True), nullable=True
197
+ )
198
+
199
+ user: Mapped["UserORM"] = relationship("UserORM", lazy="noload")
200
+ ```
201
+
202
+ ## Rules
203
+
204
+ - Routers are thin — one line per handler, everything delegated to service
205
+ - `Depends()` factory per module in `dependencies.py`
206
+ - ORM models live in `infrastructure/orm.py`, never in `domain/`
207
+ - Domain entities are plain dataclasses — no SQLAlchemy imports in `domain/`
208
+ - Abstract repository interfaces live in `infrastructure/repositories/base.py`; SQLAlchemy implementations are separate files in the same folder
209
+ - Register all routers in `app/main.py` with versioned prefix
210
+ - Use `server_default=func.now()` for `created_at` — not Python-side `datetime.now()` in ORM defaults
211
+ - Do NOT scaffold `events.py`, `value_objects.py`, or `tasks.py` unless the module actually needs them