red64-cli 0.1.0 → 0.3.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 +1 -2
- package/dist/cli/parseArgs.d.ts.map +1 -1
- package/dist/cli/parseArgs.js +5 -0
- package/dist/cli/parseArgs.js.map +1 -1
- package/dist/components/init/CompleteStep.d.ts.map +1 -1
- package/dist/components/init/CompleteStep.js +2 -2
- package/dist/components/init/CompleteStep.js.map +1 -1
- package/dist/components/init/TestCheckStep.d.ts +16 -0
- package/dist/components/init/TestCheckStep.d.ts.map +1 -0
- package/dist/components/init/TestCheckStep.js +120 -0
- package/dist/components/init/TestCheckStep.js.map +1 -0
- package/dist/components/init/index.d.ts +1 -0
- package/dist/components/init/index.d.ts.map +1 -1
- package/dist/components/init/index.js +1 -0
- package/dist/components/init/index.js.map +1 -1
- package/dist/components/init/types.d.ts +9 -0
- package/dist/components/init/types.d.ts.map +1 -1
- package/dist/components/screens/InitScreen.d.ts.map +1 -1
- package/dist/components/screens/InitScreen.js +69 -6
- package/dist/components/screens/InitScreen.js.map +1 -1
- package/dist/components/screens/ListScreen.d.ts.map +1 -1
- package/dist/components/screens/ListScreen.js +28 -3
- package/dist/components/screens/ListScreen.js.map +1 -1
- package/dist/components/screens/StartScreen.d.ts.map +1 -1
- package/dist/components/screens/StartScreen.js +212 -13
- package/dist/components/screens/StartScreen.js.map +1 -1
- package/dist/components/ui/ArtifactsSidebar.d.ts +19 -0
- package/dist/components/ui/ArtifactsSidebar.d.ts.map +1 -0
- package/dist/components/ui/ArtifactsSidebar.js +51 -0
- package/dist/components/ui/ArtifactsSidebar.js.map +1 -0
- package/dist/components/ui/FeatureSidebar.d.ts.map +1 -1
- package/dist/components/ui/FeatureSidebar.js +1 -1
- package/dist/components/ui/FeatureSidebar.js.map +1 -1
- package/dist/components/ui/index.d.ts +1 -0
- package/dist/components/ui/index.d.ts.map +1 -1
- package/dist/components/ui/index.js +1 -0
- package/dist/components/ui/index.js.map +1 -1
- package/dist/services/ClaudeErrorDetector.js +3 -3
- package/dist/services/ClaudeErrorDetector.js.map +1 -1
- package/dist/services/ConfigService.d.ts +1 -0
- package/dist/services/ConfigService.d.ts.map +1 -1
- package/dist/services/ConfigService.js.map +1 -1
- package/dist/services/ProjectDetector.d.ts +28 -0
- package/dist/services/ProjectDetector.d.ts.map +1 -0
- package/dist/services/ProjectDetector.js +236 -0
- package/dist/services/ProjectDetector.js.map +1 -0
- package/dist/services/TestRunner.d.ts +46 -0
- package/dist/services/TestRunner.d.ts.map +1 -0
- package/dist/services/TestRunner.js +85 -0
- package/dist/services/TestRunner.js.map +1 -0
- package/dist/services/index.d.ts +2 -0
- package/dist/services/index.d.ts.map +1 -1
- package/dist/services/index.js +2 -0
- package/dist/services/index.js.map +1 -1
- package/dist/types/index.d.ts +13 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/index.js.map +1 -1
- package/framework/.red64/settings/templates/specs/gap-analysis.md +163 -0
- package/framework/agents/claude/.claude/agents/red64/spec-impl.md +131 -2
- package/framework/agents/claude/.claude/agents/red64/validate-gap.md +13 -7
- package/framework/agents/claude/.claude/commands/red64/spec-impl.md +24 -0
- package/framework/agents/claude/.claude/commands/red64/validate-gap.md +4 -0
- package/framework/agents/codex/.codex/agents/red64/spec-impl.md +131 -2
- package/framework/agents/codex/.codex/agents/red64/validate-gap.md +13 -7
- package/framework/agents/codex/.codex/commands/red64/spec-impl.md +24 -0
- package/framework/agents/codex/.codex/commands/red64/validate-gap.md +4 -0
- package/framework/stacks/generic/feedback.md +80 -0
- package/framework/stacks/nextjs/accessibility.md +437 -0
- package/framework/stacks/nextjs/api.md +431 -0
- package/framework/stacks/nextjs/coding-style.md +282 -0
- package/framework/stacks/nextjs/commenting.md +226 -0
- package/framework/stacks/nextjs/components.md +411 -0
- package/framework/stacks/nextjs/conventions.md +333 -0
- package/framework/stacks/nextjs/css.md +310 -0
- package/framework/stacks/nextjs/error-handling.md +442 -0
- package/framework/stacks/nextjs/feedback.md +124 -0
- package/framework/stacks/nextjs/migrations.md +332 -0
- package/framework/stacks/nextjs/models.md +362 -0
- package/framework/stacks/nextjs/queries.md +410 -0
- package/framework/stacks/nextjs/responsive.md +338 -0
- package/framework/stacks/nextjs/tech-stack.md +177 -0
- package/framework/stacks/nextjs/test-writing.md +475 -0
- package/framework/stacks/nextjs/validation.md +467 -0
- package/framework/stacks/python/api.md +468 -0
- package/framework/stacks/python/authentication.md +342 -0
- package/framework/stacks/python/code-quality.md +283 -0
- package/framework/stacks/python/code-refactoring.md +315 -0
- package/framework/stacks/python/coding-style.md +462 -0
- package/framework/stacks/python/conventions.md +399 -0
- package/framework/stacks/python/error-handling.md +512 -0
- package/framework/stacks/python/feedback.md +92 -0
- package/framework/stacks/python/implement-ai-llm.md +468 -0
- package/framework/stacks/python/migrations.md +388 -0
- package/framework/stacks/python/models.md +399 -0
- package/framework/stacks/python/python.md +232 -0
- package/framework/stacks/python/queries.md +451 -0
- package/framework/stacks/python/structure.md +245 -58
- package/framework/stacks/python/tech.md +92 -35
- package/framework/stacks/python/testing.md +380 -0
- package/framework/stacks/python/validation.md +471 -0
- package/framework/stacks/rails/authentication.md +176 -0
- package/framework/stacks/rails/code-quality.md +287 -0
- package/framework/stacks/rails/code-refactoring.md +299 -0
- package/framework/stacks/rails/feedback.md +130 -0
- package/framework/stacks/rails/implement-ai-llm-with-rubyllm.md +342 -0
- package/framework/stacks/rails/rails.md +301 -0
- package/framework/stacks/rails/rails8-best-practices.md +498 -0
- package/framework/stacks/rails/rails8-css.md +573 -0
- package/framework/stacks/rails/structure.md +140 -0
- package/framework/stacks/rails/tech.md +108 -0
- package/framework/stacks/react/code-quality.md +521 -0
- package/framework/stacks/react/components.md +625 -0
- package/framework/stacks/react/data-fetching.md +586 -0
- package/framework/stacks/react/feedback.md +110 -0
- package/framework/stacks/react/forms.md +694 -0
- package/framework/stacks/react/performance.md +640 -0
- package/framework/stacks/react/product.md +22 -9
- package/framework/stacks/react/state-management.md +472 -0
- package/framework/stacks/react/structure.md +351 -44
- package/framework/stacks/react/tech.md +219 -30
- package/framework/stacks/react/testing.md +690 -0
- package/package.json +1 -1
- package/framework/stacks/node/product.md +0 -27
- package/framework/stacks/node/structure.md +0 -82
- package/framework/stacks/node/tech.md +0 -63
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
# SQLAlchemy Model Patterns
|
|
2
|
+
|
|
3
|
+
Best practices for SQLAlchemy 2.0 declarative models in modern Python projects.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Philosophy
|
|
8
|
+
|
|
9
|
+
- **Database enforces integrity**: Constraints live in the schema, not just application code
|
|
10
|
+
- **Models are thin**: Business logic belongs in services, not models
|
|
11
|
+
- **Explicit relationships**: Define loading strategy at query time, not model time
|
|
12
|
+
- **Type-safe by default**: Use `Mapped[]` annotations for all columns
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## Base Model
|
|
17
|
+
|
|
18
|
+
### Declarative Base with Conventions
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
# app/models/base.py
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from sqlalchemy import MetaData, func
|
|
26
|
+
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
|
27
|
+
|
|
28
|
+
# Naming conventions for constraints (required for Alembic)
|
|
29
|
+
convention = {
|
|
30
|
+
"ix": "ix_%(column_0_label)s",
|
|
31
|
+
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
|
32
|
+
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
|
33
|
+
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
|
34
|
+
"pk": "pk_%(table_name)s",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
class Base(DeclarativeBase):
|
|
38
|
+
metadata = MetaData(naming_convention=convention)
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Why naming conventions**: Alembic needs predictable constraint names to generate reliable downgrade migrations. Without them, auto-generated names differ across databases.
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Timestamp Mixin
|
|
46
|
+
|
|
47
|
+
### Always Include Timestamps
|
|
48
|
+
|
|
49
|
+
```python
|
|
50
|
+
# app/models/mixins.py
|
|
51
|
+
from datetime import datetime
|
|
52
|
+
from sqlalchemy import func
|
|
53
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
54
|
+
|
|
55
|
+
class TimestampMixin:
|
|
56
|
+
"""Add created_at and updated_at to any model."""
|
|
57
|
+
|
|
58
|
+
created_at: Mapped[datetime] = mapped_column(
|
|
59
|
+
server_default=func.now(),
|
|
60
|
+
nullable=False,
|
|
61
|
+
)
|
|
62
|
+
updated_at: Mapped[datetime] = mapped_column(
|
|
63
|
+
server_default=func.now(),
|
|
64
|
+
onupdate=func.now(),
|
|
65
|
+
nullable=False,
|
|
66
|
+
)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**Usage**: Every table gets timestamps. No exceptions.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
class User(TimestampMixin, Base):
|
|
73
|
+
__tablename__ = "users"
|
|
74
|
+
...
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
---
|
|
78
|
+
|
|
79
|
+
## Model Definition (SQLAlchemy 2.0 Style)
|
|
80
|
+
|
|
81
|
+
### Complete Example
|
|
82
|
+
|
|
83
|
+
```python
|
|
84
|
+
# app/models/user.py
|
|
85
|
+
from __future__ import annotations
|
|
86
|
+
|
|
87
|
+
from sqlalchemy import String, Text
|
|
88
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
89
|
+
|
|
90
|
+
from app.models.base import Base
|
|
91
|
+
from app.models.mixins import TimestampMixin, SoftDeleteMixin
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class User(TimestampMixin, SoftDeleteMixin, Base):
|
|
95
|
+
__tablename__ = "users"
|
|
96
|
+
|
|
97
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
98
|
+
email: Mapped[str] = mapped_column(String(255), unique=True, index=True)
|
|
99
|
+
name: Mapped[str] = mapped_column(String(255))
|
|
100
|
+
hashed_password: Mapped[str] = mapped_column(String(255))
|
|
101
|
+
bio: Mapped[str | None] = mapped_column(Text, default=None)
|
|
102
|
+
is_active: Mapped[bool] = mapped_column(default=True)
|
|
103
|
+
role: Mapped[str] = mapped_column(String(20), default="member")
|
|
104
|
+
|
|
105
|
+
# Relationships
|
|
106
|
+
posts: Mapped[list[Post]] = relationship(back_populates="author", cascade="all, delete-orphan")
|
|
107
|
+
|
|
108
|
+
def __repr__(self) -> str:
|
|
109
|
+
return f"<User id={self.id} email={self.email!r}>"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Key Conventions
|
|
113
|
+
|
|
114
|
+
| Convention | Example | Reason |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| Singular model name | `User`, not `Users` | Python class convention |
|
|
117
|
+
| Plural table name | `"users"` | SQL convention |
|
|
118
|
+
| `Mapped[type]` for all columns | `Mapped[str]` | Type safety, IDE support |
|
|
119
|
+
| `Mapped[T \| None]` for nullable | `Mapped[str \| None]` | Explicit nullability |
|
|
120
|
+
| String lengths on VARCHAR | `String(255)` | Prevent unbounded columns |
|
|
121
|
+
| Index on foreign keys | `index=True` | Query performance |
|
|
122
|
+
|
|
123
|
+
---
|
|
124
|
+
|
|
125
|
+
## Data Integrity
|
|
126
|
+
|
|
127
|
+
### Constraints
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from sqlalchemy import CheckConstraint, UniqueConstraint, Index
|
|
131
|
+
|
|
132
|
+
class Order(TimestampMixin, Base):
|
|
133
|
+
__tablename__ = "orders"
|
|
134
|
+
__table_args__ = (
|
|
135
|
+
UniqueConstraint("user_id", "external_id", name="uq_orders_user_external"),
|
|
136
|
+
CheckConstraint("total_cents >= 0", name="positive_total"),
|
|
137
|
+
Index("ix_orders_user_status", "user_id", "status"),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
141
|
+
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
|
142
|
+
external_id: Mapped[str] = mapped_column(String(100))
|
|
143
|
+
status: Mapped[str] = mapped_column(String(20), default="pending")
|
|
144
|
+
total_cents: Mapped[int] = mapped_column()
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### When to Use Each Constraint
|
|
148
|
+
|
|
149
|
+
| Constraint | Use Case |
|
|
150
|
+
|---|---|
|
|
151
|
+
| `unique=True` | Single-column uniqueness (email, slug) |
|
|
152
|
+
| `UniqueConstraint` | Multi-column uniqueness |
|
|
153
|
+
| `CheckConstraint` | Value range, format rules |
|
|
154
|
+
| `ForeignKey` | Referential integrity |
|
|
155
|
+
| `Index` | Composite indexes, partial indexes |
|
|
156
|
+
| `nullable=False` (default for `Mapped[T]`) | Required fields |
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## Relationships
|
|
161
|
+
|
|
162
|
+
### Loading Strategies
|
|
163
|
+
|
|
164
|
+
```python
|
|
165
|
+
class Post(TimestampMixin, Base):
|
|
166
|
+
__tablename__ = "posts"
|
|
167
|
+
|
|
168
|
+
id: Mapped[int] = mapped_column(primary_key=True)
|
|
169
|
+
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"), index=True)
|
|
170
|
+
title: Mapped[str] = mapped_column(String(500))
|
|
171
|
+
body: Mapped[str] = mapped_column(Text)
|
|
172
|
+
status: Mapped[str] = mapped_column(String(20), default="draft")
|
|
173
|
+
|
|
174
|
+
# Define relationship, but do NOT set eager loading here
|
|
175
|
+
author: Mapped[User] = relationship(back_populates="posts")
|
|
176
|
+
tags: Mapped[list[Tag]] = relationship(secondary="post_tags", back_populates="posts")
|
|
177
|
+
comments: Mapped[list[Comment]] = relationship(back_populates="post", cascade="all, delete-orphan")
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
**Rule**: Never set `lazy="joined"` or `lazy="selectin"` on the model. Choose loading strategy at query time:
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
# At query time, choose the right strategy
|
|
184
|
+
from sqlalchemy.orm import selectinload, joinedload
|
|
185
|
+
|
|
186
|
+
# For collections: selectinload (separate SELECT IN query)
|
|
187
|
+
stmt = select(Post).options(selectinload(Post.comments))
|
|
188
|
+
|
|
189
|
+
# For single relations: joinedload (JOIN in same query)
|
|
190
|
+
stmt = select(Post).options(joinedload(Post.author))
|
|
191
|
+
|
|
192
|
+
# Nested loading
|
|
193
|
+
stmt = select(User).options(
|
|
194
|
+
selectinload(User.posts).selectinload(Post.comments)
|
|
195
|
+
)
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
### Cascade Behaviors
|
|
199
|
+
|
|
200
|
+
```python
|
|
201
|
+
# Delete orphans when parent is deleted
|
|
202
|
+
posts: Mapped[list[Post]] = relationship(cascade="all, delete-orphan")
|
|
203
|
+
|
|
204
|
+
# Nullify foreign key when parent is deleted
|
|
205
|
+
posts: Mapped[list[Post]] = relationship(passive_deletes=True)
|
|
206
|
+
# Requires: ForeignKey("users.id", ondelete="SET NULL")
|
|
207
|
+
|
|
208
|
+
# Database-level cascade (preferred for performance)
|
|
209
|
+
user_id: Mapped[int] = mapped_column(
|
|
210
|
+
ForeignKey("users.id", ondelete="CASCADE"),
|
|
211
|
+
index=True,
|
|
212
|
+
)
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
---
|
|
216
|
+
|
|
217
|
+
## Soft Deletes
|
|
218
|
+
|
|
219
|
+
### Mixin Pattern
|
|
220
|
+
|
|
221
|
+
```python
|
|
222
|
+
# app/models/mixins.py
|
|
223
|
+
from datetime import datetime
|
|
224
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
225
|
+
|
|
226
|
+
class SoftDeleteMixin:
|
|
227
|
+
"""Soft delete support. Query with .where(Model.deleted_at.is_(None))."""
|
|
228
|
+
|
|
229
|
+
deleted_at: Mapped[datetime | None] = mapped_column(default=None, index=True)
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def is_deleted(self) -> bool:
|
|
233
|
+
return self.deleted_at is not None
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
### Querying with Soft Deletes
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
# Active records only
|
|
240
|
+
stmt = select(User).where(User.deleted_at.is_(None))
|
|
241
|
+
|
|
242
|
+
# Include deleted
|
|
243
|
+
stmt = select(User)
|
|
244
|
+
|
|
245
|
+
# Deleted only
|
|
246
|
+
stmt = select(User).where(User.deleted_at.is_not(None))
|
|
247
|
+
|
|
248
|
+
# Soft delete operation
|
|
249
|
+
user.deleted_at = datetime.now(timezone.utc)
|
|
250
|
+
await db.commit()
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## Hybrid Properties
|
|
256
|
+
|
|
257
|
+
### Computed Values
|
|
258
|
+
|
|
259
|
+
```python
|
|
260
|
+
from sqlalchemy.ext.hybrid import hybrid_property
|
|
261
|
+
|
|
262
|
+
class User(TimestampMixin, Base):
|
|
263
|
+
__tablename__ = "users"
|
|
264
|
+
|
|
265
|
+
first_name: Mapped[str] = mapped_column(String(100))
|
|
266
|
+
last_name: Mapped[str] = mapped_column(String(100))
|
|
267
|
+
|
|
268
|
+
@hybrid_property
|
|
269
|
+
def full_name(self) -> str:
|
|
270
|
+
return f"{self.first_name} {self.last_name}"
|
|
271
|
+
|
|
272
|
+
@full_name.expression
|
|
273
|
+
@classmethod
|
|
274
|
+
def full_name(cls):
|
|
275
|
+
return cls.first_name + " " + cls.last_name
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
This allows both Python-side and SQL-side usage:
|
|
279
|
+
|
|
280
|
+
```python
|
|
281
|
+
# Python
|
|
282
|
+
user.full_name # "John Doe"
|
|
283
|
+
|
|
284
|
+
# SQL
|
|
285
|
+
stmt = select(User).where(User.full_name == "John Doe")
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
---
|
|
289
|
+
|
|
290
|
+
## Pydantic Integration
|
|
291
|
+
|
|
292
|
+
### Model to Schema
|
|
293
|
+
|
|
294
|
+
```python
|
|
295
|
+
# app/schemas/user.py
|
|
296
|
+
from pydantic import BaseModel, ConfigDict
|
|
297
|
+
|
|
298
|
+
class UserResponse(BaseModel):
|
|
299
|
+
model_config = ConfigDict(from_attributes=True)
|
|
300
|
+
|
|
301
|
+
id: int
|
|
302
|
+
email: str
|
|
303
|
+
name: str
|
|
304
|
+
is_active: bool
|
|
305
|
+
created_at: datetime
|
|
306
|
+
|
|
307
|
+
class UserCreate(BaseModel):
|
|
308
|
+
email: str
|
|
309
|
+
name: str
|
|
310
|
+
password: str # Plain text, hashed before storage
|
|
311
|
+
|
|
312
|
+
class UserUpdate(BaseModel):
|
|
313
|
+
name: str | None = None
|
|
314
|
+
bio: str | None = None
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
### Usage
|
|
318
|
+
|
|
319
|
+
```python
|
|
320
|
+
# SQLAlchemy model -> Pydantic schema
|
|
321
|
+
user = await db.get(User, user_id)
|
|
322
|
+
response = UserResponse.model_validate(user)
|
|
323
|
+
|
|
324
|
+
# Pydantic schema -> dict for creation
|
|
325
|
+
data = UserCreate(email="a@b.com", name="Test", password="secret")
|
|
326
|
+
user = User(**data.model_dump(exclude={"password"}), hashed_password=hash_password(data.password))
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
---
|
|
330
|
+
|
|
331
|
+
## Enums
|
|
332
|
+
|
|
333
|
+
### Use Python StrEnum
|
|
334
|
+
|
|
335
|
+
```python
|
|
336
|
+
from enum import StrEnum
|
|
337
|
+
|
|
338
|
+
class PostStatus(StrEnum):
|
|
339
|
+
DRAFT = "draft"
|
|
340
|
+
PUBLISHED = "published"
|
|
341
|
+
ARCHIVED = "archived"
|
|
342
|
+
|
|
343
|
+
class Post(TimestampMixin, Base):
|
|
344
|
+
__tablename__ = "posts"
|
|
345
|
+
|
|
346
|
+
# Store as string, not database ENUM (easier migrations)
|
|
347
|
+
status: Mapped[str] = mapped_column(String(20), default=PostStatus.DRAFT)
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
**Why string over DB ENUM**: Adding values to a PostgreSQL ENUM requires a migration. String columns with application-level validation are simpler to evolve.
|
|
351
|
+
|
|
352
|
+
---
|
|
353
|
+
|
|
354
|
+
## Association Tables
|
|
355
|
+
|
|
356
|
+
### Many-to-Many
|
|
357
|
+
|
|
358
|
+
```python
|
|
359
|
+
from sqlalchemy import Table, Column, ForeignKey
|
|
360
|
+
|
|
361
|
+
post_tags = Table(
|
|
362
|
+
"post_tags",
|
|
363
|
+
Base.metadata,
|
|
364
|
+
Column("post_id", ForeignKey("posts.id", ondelete="CASCADE"), primary_key=True),
|
|
365
|
+
Column("tag_id", ForeignKey("tags.id", ondelete="CASCADE"), primary_key=True),
|
|
366
|
+
)
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
### Association Object (with extra data)
|
|
370
|
+
|
|
371
|
+
```python
|
|
372
|
+
class PostTag(TimestampMixin, Base):
|
|
373
|
+
__tablename__ = "post_tags"
|
|
374
|
+
|
|
375
|
+
post_id: Mapped[int] = mapped_column(ForeignKey("posts.id"), primary_key=True)
|
|
376
|
+
tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True)
|
|
377
|
+
added_by: Mapped[int] = mapped_column(ForeignKey("users.id"))
|
|
378
|
+
position: Mapped[int] = mapped_column(default=0)
|
|
379
|
+
|
|
380
|
+
post: Mapped[Post] = relationship()
|
|
381
|
+
tag: Mapped[Tag] = relationship()
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
---
|
|
385
|
+
|
|
386
|
+
## Anti-Patterns
|
|
387
|
+
|
|
388
|
+
| Anti-Pattern | Problem | Correct Approach |
|
|
389
|
+
|---|---|---|
|
|
390
|
+
| Business logic in models | Models become hard to test | Put logic in service layer |
|
|
391
|
+
| `lazy="joined"` on model | Always loads relation, N+1 risk | Choose strategy at query time |
|
|
392
|
+
| No constraint naming convention | Alembic generates unstable names | Use `MetaData(naming_convention=...)` |
|
|
393
|
+
| Missing `__repr__` | Debugging is painful | Always define `__repr__` |
|
|
394
|
+
| `String()` without length | Unbounded columns, DB-specific behavior | Always specify `String(n)` |
|
|
395
|
+
| Importing models in migrations | Models change, migrations break | Use `sa.table()` in migrations |
|
|
396
|
+
|
|
397
|
+
---
|
|
398
|
+
|
|
399
|
+
_Models define structure and integrity. Business rules belong in services. Loading strategies belong in queries._
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# Python Conventions
|
|
2
|
+
|
|
3
|
+
Project memory for modern Python 3.12+ patterns and conventions.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Language Stack
|
|
8
|
+
|
|
9
|
+
### Core Technologies
|
|
10
|
+
- **Python 3.12+** with modern typing features
|
|
11
|
+
- **uv**: Fast package management and virtual environments
|
|
12
|
+
- **Pydantic v2**: Data validation and settings management
|
|
13
|
+
- **asyncio**: Native async/await for concurrent operations
|
|
14
|
+
- **structlog**: Structured logging
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Type System
|
|
19
|
+
|
|
20
|
+
### Modern Typing (Python 3.12+)
|
|
21
|
+
|
|
22
|
+
Use built-in generics and the new `type` statement:
|
|
23
|
+
|
|
24
|
+
```python
|
|
25
|
+
# Pattern: Modern type aliases (3.12+)
|
|
26
|
+
type UserId = int
|
|
27
|
+
type Result[T] = T | None
|
|
28
|
+
type Handler = Callable[[Request], Awaitable[Response]]
|
|
29
|
+
|
|
30
|
+
# Pattern: Built-in generics (3.10+), no imports needed
|
|
31
|
+
def get_items(ids: list[int]) -> dict[str, Item]:
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
# Pattern: Union with pipe operator (3.10+)
|
|
35
|
+
def find_user(key: str | int) -> User | None:
|
|
36
|
+
...
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
### Protocols Over ABCs
|
|
40
|
+
|
|
41
|
+
Prefer `Protocol` for structural subtyping:
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from typing import Protocol, runtime_checkable
|
|
45
|
+
|
|
46
|
+
@runtime_checkable
|
|
47
|
+
class Repository(Protocol):
|
|
48
|
+
async def get(self, id: str) -> dict: ...
|
|
49
|
+
async def save(self, entity: dict) -> None: ...
|
|
50
|
+
|
|
51
|
+
# Any class with matching methods satisfies the protocol
|
|
52
|
+
class PostgresRepository:
|
|
53
|
+
async def get(self, id: str) -> dict:
|
|
54
|
+
...
|
|
55
|
+
async def save(self, entity: dict) -> None:
|
|
56
|
+
...
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
## Data Modeling
|
|
62
|
+
|
|
63
|
+
### Pydantic for External Data
|
|
64
|
+
|
|
65
|
+
Use Pydantic models for API boundaries, config, and validation:
|
|
66
|
+
|
|
67
|
+
```python
|
|
68
|
+
from pydantic import BaseModel, Field, field_validator
|
|
69
|
+
|
|
70
|
+
class CreateUserRequest(BaseModel):
|
|
71
|
+
email: str = Field(..., pattern=r"^[\w.+-]+@[\w-]+\.[\w.]+$")
|
|
72
|
+
name: str = Field(..., min_length=1, max_length=100)
|
|
73
|
+
role: str = "member"
|
|
74
|
+
|
|
75
|
+
@field_validator("role")
|
|
76
|
+
@classmethod
|
|
77
|
+
def validate_role(cls, v: str) -> str:
|
|
78
|
+
if v not in ("admin", "member", "viewer"):
|
|
79
|
+
raise ValueError(f"Invalid role: {v}")
|
|
80
|
+
return v
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### Dataclasses for Internal Data
|
|
84
|
+
|
|
85
|
+
Use `dataclass` for simple internal value objects:
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
from dataclasses import dataclass, field
|
|
89
|
+
from datetime import datetime
|
|
90
|
+
|
|
91
|
+
@dataclass(frozen=True, slots=True)
|
|
92
|
+
class AuditEntry:
|
|
93
|
+
action: str
|
|
94
|
+
user_id: str
|
|
95
|
+
timestamp: datetime = field(default_factory=datetime.utcnow)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
---
|
|
99
|
+
|
|
100
|
+
## Async Patterns
|
|
101
|
+
|
|
102
|
+
### Async Service Pattern
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
import asyncio
|
|
106
|
+
|
|
107
|
+
class ContentService:
|
|
108
|
+
def __init__(self, repo: Repository, cache: CacheClient) -> None:
|
|
109
|
+
self._repo = repo
|
|
110
|
+
self._cache = cache
|
|
111
|
+
|
|
112
|
+
async def get_content(self, content_id: str) -> Content:
|
|
113
|
+
cached = await self._cache.get(f"content:{content_id}")
|
|
114
|
+
if cached:
|
|
115
|
+
return Content.model_validate_json(cached)
|
|
116
|
+
content = await self._repo.get(content_id)
|
|
117
|
+
await self._cache.set(f"content:{content_id}", content.model_dump_json())
|
|
118
|
+
return content
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
### Concurrency with gather
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
# Pattern: Parallel async operations
|
|
125
|
+
async def enrich_sources(sources: list[Source]) -> list[EnrichedSource]:
|
|
126
|
+
tasks = [enrich_single(s) for s in sources]
|
|
127
|
+
return await asyncio.gather(*tasks, return_exceptions=True)
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### Async Context Managers
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from contextlib import asynccontextmanager
|
|
134
|
+
from collections.abc import AsyncIterator
|
|
135
|
+
|
|
136
|
+
@asynccontextmanager
|
|
137
|
+
async def db_session() -> AsyncIterator[AsyncSession]:
|
|
138
|
+
session = AsyncSession(engine)
|
|
139
|
+
try:
|
|
140
|
+
yield session
|
|
141
|
+
await session.commit()
|
|
142
|
+
except Exception:
|
|
143
|
+
await session.rollback()
|
|
144
|
+
raise
|
|
145
|
+
finally:
|
|
146
|
+
await session.close()
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
---
|
|
150
|
+
|
|
151
|
+
## Error Handling
|
|
152
|
+
|
|
153
|
+
### Exception Hierarchy
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
# Pattern: Domain-specific exception hierarchy
|
|
157
|
+
class AppError(Exception):
|
|
158
|
+
"""Base application error."""
|
|
159
|
+
def __init__(self, message: str, code: str = "INTERNAL_ERROR") -> None:
|
|
160
|
+
self.message = message
|
|
161
|
+
self.code = code
|
|
162
|
+
super().__init__(message)
|
|
163
|
+
|
|
164
|
+
class NotFoundError(AppError):
|
|
165
|
+
def __init__(self, resource: str, id: str) -> None:
|
|
166
|
+
super().__init__(f"{resource} {id} not found", code="NOT_FOUND")
|
|
167
|
+
|
|
168
|
+
class ValidationError(AppError):
|
|
169
|
+
def __init__(self, details: list[str]) -> None:
|
|
170
|
+
self.details = details
|
|
171
|
+
super().__init__("; ".join(details), code="VALIDATION_ERROR")
|
|
172
|
+
|
|
173
|
+
class ExternalServiceError(AppError):
|
|
174
|
+
def __init__(self, service: str, message: str) -> None:
|
|
175
|
+
super().__init__(f"{service}: {message}", code="EXTERNAL_ERROR")
|
|
176
|
+
```
|
|
177
|
+
|
|
178
|
+
---
|
|
179
|
+
|
|
180
|
+
## Result Pattern
|
|
181
|
+
|
|
182
|
+
```python
|
|
183
|
+
from dataclasses import dataclass
|
|
184
|
+
from typing import Generic, TypeVar
|
|
185
|
+
|
|
186
|
+
T = TypeVar("T")
|
|
187
|
+
|
|
188
|
+
@dataclass(frozen=True)
|
|
189
|
+
class Result(Generic[T]):
|
|
190
|
+
value: T | None = None
|
|
191
|
+
error: str | None = None
|
|
192
|
+
|
|
193
|
+
@property
|
|
194
|
+
def is_ok(self) -> bool:
|
|
195
|
+
return self.error is None
|
|
196
|
+
|
|
197
|
+
@classmethod
|
|
198
|
+
def ok(cls, value: T) -> "Result[T]":
|
|
199
|
+
return cls(value=value)
|
|
200
|
+
|
|
201
|
+
@classmethod
|
|
202
|
+
def fail(cls, error: str) -> "Result[T]":
|
|
203
|
+
return cls(error=error)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## Naming Conventions
|
|
209
|
+
|
|
210
|
+
| Type | Pattern | Example |
|
|
211
|
+
|------|---------|---------|
|
|
212
|
+
| Modules | snake_case | `user_service.py`, `auth_middleware.py` |
|
|
213
|
+
| Classes | PascalCase | `UserService`, `CreateUserRequest` |
|
|
214
|
+
| Functions | snake_case | `get_user_by_email()` |
|
|
215
|
+
| Constants | UPPER_SNAKE | `MAX_RETRIES`, `DEFAULT_TIMEOUT` |
|
|
216
|
+
| Type aliases | PascalCase | `type UserId = int` |
|
|
217
|
+
| Private | Leading underscore | `_validate_input()`, `_cache` |
|
|
218
|
+
| Protocols | PascalCase, noun | `Repository`, `EventPublisher` |
|
|
219
|
+
|
|
220
|
+
---
|
|
221
|
+
|
|
222
|
+
## Code Style Principles
|
|
223
|
+
|
|
224
|
+
1. **Explicit over implicit**: Type all function signatures
|
|
225
|
+
2. **Composition over inheritance**: Prefer protocols and dependency injection
|
|
226
|
+
3. **Immutable by default**: Use `frozen=True` dataclasses, avoid mutation
|
|
227
|
+
4. **Fail fast**: Validate at boundaries, propagate errors clearly
|
|
228
|
+
5. **Flat is better**: Avoid deep nesting; use early returns and guard clauses
|
|
229
|
+
|
|
230
|
+
---
|
|
231
|
+
|
|
232
|
+
_Document patterns, not every function. Code should be typed, testable, and explicit._
|