skrift 0.1.0a15__py3-none-any.whl → 0.1.0a17__py3-none-any.whl
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.
- skrift/asgi.py +6 -2
- skrift/claude_skill/SKILL.md +205 -0
- skrift/claude_skill/__init__.py +0 -0
- skrift/claude_skill/architecture.md +328 -0
- skrift/claude_skill/patterns.md +552 -0
- skrift/cli.py +47 -0
- skrift/config.py +1 -0
- skrift/db/session.py +90 -0
- skrift/setup/config_writer.py +1 -0
- skrift/setup/state.py +4 -4
- {skrift-0.1.0a15.dist-info → skrift-0.1.0a17.dist-info}/METADATA +1 -1
- {skrift-0.1.0a15.dist-info → skrift-0.1.0a17.dist-info}/RECORD +14 -9
- {skrift-0.1.0a15.dist-info → skrift-0.1.0a17.dist-info}/WHEEL +0 -0
- {skrift-0.1.0a15.dist-info → skrift-0.1.0a17.dist-info}/entry_points.txt +0 -0
skrift/asgi.py
CHANGED
|
@@ -22,6 +22,8 @@ from advanced_alchemy.extensions.litestar import (
|
|
|
22
22
|
SQLAlchemyAsyncConfig,
|
|
23
23
|
SQLAlchemyPlugin,
|
|
24
24
|
)
|
|
25
|
+
|
|
26
|
+
from skrift.db.session import SafeSQLAlchemyAsyncConfig
|
|
25
27
|
from litestar import Litestar
|
|
26
28
|
from litestar.config.compression import CompressionConfig
|
|
27
29
|
from litestar.contrib.jinja import JinjaTemplateEngine
|
|
@@ -398,10 +400,11 @@ def create_app() -> Litestar:
|
|
|
398
400
|
pool_size=settings.db.pool_size,
|
|
399
401
|
max_overflow=settings.db.pool_overflow,
|
|
400
402
|
pool_timeout=settings.db.pool_timeout,
|
|
403
|
+
pool_pre_ping=settings.db.pool_pre_ping,
|
|
401
404
|
echo=settings.db.echo,
|
|
402
405
|
)
|
|
403
406
|
|
|
404
|
-
db_config =
|
|
407
|
+
db_config = SafeSQLAlchemyAsyncConfig(
|
|
405
408
|
connection_string=settings.db.url,
|
|
406
409
|
metadata=Base.metadata,
|
|
407
410
|
create_all=False,
|
|
@@ -564,10 +567,11 @@ def create_setup_app() -> Litestar:
|
|
|
564
567
|
pool_size=5,
|
|
565
568
|
max_overflow=10,
|
|
566
569
|
pool_timeout=30,
|
|
570
|
+
pool_pre_ping=True,
|
|
567
571
|
echo=False,
|
|
568
572
|
)
|
|
569
573
|
|
|
570
|
-
db_config =
|
|
574
|
+
db_config = SafeSQLAlchemyAsyncConfig(
|
|
571
575
|
connection_string=db_url,
|
|
572
576
|
metadata=Base.metadata,
|
|
573
577
|
create_all=False,
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: skrift
|
|
3
|
+
description: "Help working with Skrift CMS codebases - a Python async CMS built on Litestar with WordPress-style conventions. Use for creating controllers, models, hooks, pages, and templates."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Skrift CMS Development Guide
|
|
7
|
+
|
|
8
|
+
Skrift is a lightweight async Python CMS built on Litestar, featuring WordPress-style template resolution, a hook/filter extensibility system, and SQLAlchemy async database access.
|
|
9
|
+
|
|
10
|
+
## Current Project State
|
|
11
|
+
|
|
12
|
+
**Configuration:**
|
|
13
|
+
!`cat app.yaml 2>/dev/null || echo "No app.yaml found"`
|
|
14
|
+
|
|
15
|
+
**Controllers:**
|
|
16
|
+
!`ls skrift/controllers/*.py 2>/dev/null | head -10`
|
|
17
|
+
|
|
18
|
+
**Models:**
|
|
19
|
+
!`ls skrift/db/models/*.py 2>/dev/null | head -10`
|
|
20
|
+
|
|
21
|
+
**Services:**
|
|
22
|
+
!`ls skrift/db/services/*.py 2>/dev/null | head -10`
|
|
23
|
+
|
|
24
|
+
**Templates:**
|
|
25
|
+
!`ls templates/*.html 2>/dev/null | head -10 || echo "No custom templates"`
|
|
26
|
+
|
|
27
|
+
## Quick Reference
|
|
28
|
+
|
|
29
|
+
### Core Architecture
|
|
30
|
+
|
|
31
|
+
- **Framework**: Litestar (async Python web framework)
|
|
32
|
+
- **Database**: SQLAlchemy async with Advanced Alchemy
|
|
33
|
+
- **Templates**: Jinja2 with WordPress-style template hierarchy
|
|
34
|
+
- **Config**: YAML (app.yaml) + environment variables (.env)
|
|
35
|
+
- **Auth**: OAuth providers + role-based permissions
|
|
36
|
+
|
|
37
|
+
### Key Files
|
|
38
|
+
|
|
39
|
+
| File | Purpose |
|
|
40
|
+
|------|---------|
|
|
41
|
+
| `skrift/asgi.py` | AppDispatcher, app creation, middleware loading |
|
|
42
|
+
| `skrift/config.py` | Settings management, YAML config loading |
|
|
43
|
+
| `skrift/cli.py` | CLI commands (serve, secret, db) |
|
|
44
|
+
| `skrift/lib/hooks.py` | WordPress-like hook/filter system |
|
|
45
|
+
| `skrift/lib/template.py` | Template resolution with fallbacks |
|
|
46
|
+
| `skrift/db/base.py` | SQLAlchemy Base class (UUIDAuditBase) |
|
|
47
|
+
| `skrift/auth/` | Guards, roles, permissions |
|
|
48
|
+
|
|
49
|
+
### CLI Commands
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# Run development server
|
|
53
|
+
skrift serve --reload --port 8080
|
|
54
|
+
|
|
55
|
+
# Generate secret key
|
|
56
|
+
skrift secret # Print to stdout
|
|
57
|
+
skrift secret --write .env # Write to .env file
|
|
58
|
+
|
|
59
|
+
# Database migrations (wraps Alembic)
|
|
60
|
+
skrift db upgrade head # Apply all migrations
|
|
61
|
+
skrift db downgrade -1 # Rollback one migration
|
|
62
|
+
skrift db current # Show current revision
|
|
63
|
+
skrift db revision -m "desc" --autogenerate # Create migration
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## Task-Specific Guidance
|
|
67
|
+
|
|
68
|
+
### Creating a Controller
|
|
69
|
+
|
|
70
|
+
Controllers are Litestar Controller classes registered in `app.yaml`:
|
|
71
|
+
|
|
72
|
+
```python
|
|
73
|
+
from litestar import Controller, get, post
|
|
74
|
+
from litestar.response import Template as TemplateResponse
|
|
75
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
76
|
+
|
|
77
|
+
class MyController(Controller):
|
|
78
|
+
path = "/my-path"
|
|
79
|
+
|
|
80
|
+
@get("/")
|
|
81
|
+
async def list_items(self, db_session: AsyncSession) -> TemplateResponse:
|
|
82
|
+
# db_session is injected automatically
|
|
83
|
+
return TemplateResponse("my-template.html", context={"items": []})
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Register in `app.yaml`:
|
|
87
|
+
```yaml
|
|
88
|
+
controllers:
|
|
89
|
+
- myapp.controllers:MyController
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Creating a Database Model
|
|
93
|
+
|
|
94
|
+
Models inherit from `skrift.db.base.Base` (provides id, created_at, updated_at):
|
|
95
|
+
|
|
96
|
+
```python
|
|
97
|
+
from sqlalchemy import String, Text
|
|
98
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
99
|
+
from skrift.db.base import Base
|
|
100
|
+
|
|
101
|
+
class MyModel(Base):
|
|
102
|
+
__tablename__ = "my_models"
|
|
103
|
+
|
|
104
|
+
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
105
|
+
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Creating a Service
|
|
109
|
+
|
|
110
|
+
Services are async functions that handle database operations:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from sqlalchemy import select
|
|
114
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
115
|
+
from skrift.db.models import MyModel
|
|
116
|
+
|
|
117
|
+
async def get_item_by_id(db_session: AsyncSession, item_id: UUID) -> MyModel | None:
|
|
118
|
+
result = await db_session.execute(select(MyModel).where(MyModel.id == item_id))
|
|
119
|
+
return result.scalar_one_or_none()
|
|
120
|
+
|
|
121
|
+
async def create_item(db_session: AsyncSession, name: str) -> MyModel:
|
|
122
|
+
item = MyModel(name=name)
|
|
123
|
+
db_session.add(item)
|
|
124
|
+
await db_session.commit()
|
|
125
|
+
await db_session.refresh(item)
|
|
126
|
+
return item
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Using Hooks
|
|
130
|
+
|
|
131
|
+
The hook system provides WordPress-like extensibility:
|
|
132
|
+
|
|
133
|
+
```python
|
|
134
|
+
from skrift.lib.hooks import hooks, action, filter
|
|
135
|
+
|
|
136
|
+
# Action: Side effects (no return value needed)
|
|
137
|
+
@action("after_page_save", priority=10)
|
|
138
|
+
async def notify_on_save(page, is_new: bool):
|
|
139
|
+
print(f"Page saved: {page.title}")
|
|
140
|
+
|
|
141
|
+
# Filter: Transform values (must return value)
|
|
142
|
+
@filter("page_seo_meta", priority=10)
|
|
143
|
+
async def customize_meta(meta: dict, page) -> dict:
|
|
144
|
+
meta["author"] = "Custom Author"
|
|
145
|
+
return meta
|
|
146
|
+
|
|
147
|
+
# Trigger hooks programmatically
|
|
148
|
+
await hooks.do_action("my_action", arg1, arg2)
|
|
149
|
+
result = await hooks.apply_filters("my_filter", initial_value, arg1)
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
Built-in hooks:
|
|
153
|
+
- Actions: `before_page_save`, `after_page_save`, `before_page_delete`, `after_page_delete`
|
|
154
|
+
- Filters: `page_seo_meta`, `page_og_meta`, `sitemap_urls`, `sitemap_page`, `robots_txt`, `template_context`
|
|
155
|
+
|
|
156
|
+
### Using Guards (Authorization)
|
|
157
|
+
|
|
158
|
+
Protect routes with permission or role guards:
|
|
159
|
+
|
|
160
|
+
```python
|
|
161
|
+
from skrift.auth import auth_guard, Permission, Role
|
|
162
|
+
|
|
163
|
+
class AdminController(Controller):
|
|
164
|
+
path = "/admin"
|
|
165
|
+
guards = [auth_guard, Permission("manage-pages")]
|
|
166
|
+
|
|
167
|
+
@get("/")
|
|
168
|
+
async def admin_dashboard(self) -> TemplateResponse:
|
|
169
|
+
return TemplateResponse("admin/dashboard.html")
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Combine requirements:
|
|
173
|
+
```python
|
|
174
|
+
# AND: Both required
|
|
175
|
+
guards = [auth_guard, Permission("edit") & Permission("publish")]
|
|
176
|
+
|
|
177
|
+
# OR: Either sufficient
|
|
178
|
+
guards = [auth_guard, Role("admin") | Role("editor")]
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
### Template Resolution
|
|
182
|
+
|
|
183
|
+
WordPress-style template hierarchy with fallbacks:
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
from skrift.lib.template import Template
|
|
187
|
+
|
|
188
|
+
# Tries: page-about.html -> page.html
|
|
189
|
+
template = Template("page", "about")
|
|
190
|
+
|
|
191
|
+
# Tries: post-news-2024.html -> post-news.html -> post.html
|
|
192
|
+
template = Template("post", "news", "2024")
|
|
193
|
+
|
|
194
|
+
return template.render(TEMPLATE_DIR, page=page, extra="context")
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Templates searched in order:
|
|
198
|
+
1. `./templates/` (project directory - user overrides)
|
|
199
|
+
2. `skrift/templates/` (package directory - defaults)
|
|
200
|
+
|
|
201
|
+
## Reference Documentation
|
|
202
|
+
|
|
203
|
+
For detailed documentation, see:
|
|
204
|
+
- [Architecture Details](architecture.md)
|
|
205
|
+
- [Code Patterns](patterns.md)
|
|
File without changes
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
# Skrift Architecture
|
|
2
|
+
|
|
3
|
+
## Application Lifecycle
|
|
4
|
+
|
|
5
|
+
### AppDispatcher Pattern
|
|
6
|
+
|
|
7
|
+
Skrift uses a dispatcher architecture that routes between setup and main applications:
|
|
8
|
+
|
|
9
|
+
```
|
|
10
|
+
┌─────────────────────────────┐
|
|
11
|
+
│ AppDispatcher │
|
|
12
|
+
│ (skrift/asgi.py) │
|
|
13
|
+
└─────────────┬───────────────┘
|
|
14
|
+
│
|
|
15
|
+
┌───────────────────┼───────────────────┐
|
|
16
|
+
│ │ │
|
|
17
|
+
▼ ▼ ▼
|
|
18
|
+
┌────────────────┐ ┌────────────┐ ┌────────────────┐
|
|
19
|
+
│ Setup App │ │ /static │ │ Main App │
|
|
20
|
+
│ (/setup/*) │ │ Files │ │ (everything) │
|
|
21
|
+
└────────────────┘ └────────────┘ └────────────────┘
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
**Key behaviors:**
|
|
25
|
+
- `setup_locked=False`: /setup/* routes active, checks DB for setup completion
|
|
26
|
+
- `setup_locked=True`: All traffic goes to main app, /setup/* returns 404
|
|
27
|
+
- Main app is lazily created after setup completes (no restart needed)
|
|
28
|
+
|
|
29
|
+
**Entry point:** `skrift.asgi:app` (created by `create_dispatcher()`)
|
|
30
|
+
|
|
31
|
+
### Startup Flow
|
|
32
|
+
|
|
33
|
+
1. `create_dispatcher()` called at module load
|
|
34
|
+
2. Checks if setup complete in database
|
|
35
|
+
3. If complete + config valid: returns `create_app()` directly
|
|
36
|
+
4. Otherwise: returns `AppDispatcher` with lazy main app creation
|
|
37
|
+
|
|
38
|
+
### Request Flow
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
Request → AppDispatcher → Route Decision:
|
|
42
|
+
│
|
|
43
|
+
├─ /setup/* or /static/* → Setup App
|
|
44
|
+
│
|
|
45
|
+
├─ Setup not complete → Check DB:
|
|
46
|
+
│ ├─ Complete → Create main app, lock setup, route to main
|
|
47
|
+
│ └─ Not complete → Redirect to /setup
|
|
48
|
+
│
|
|
49
|
+
└─ Setup locked → Main App (handles everything including 404 for /setup/*)
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## Configuration System
|
|
53
|
+
|
|
54
|
+
### Configuration Flow
|
|
55
|
+
|
|
56
|
+
```
|
|
57
|
+
.env (loaded early) → app.yaml (with $VAR interpolation) → Settings (Pydantic)
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Environment-Specific Config
|
|
61
|
+
|
|
62
|
+
| Environment | Config File |
|
|
63
|
+
|-------------|-------------|
|
|
64
|
+
| production (default) | `app.yaml` |
|
|
65
|
+
| development | `app.dev.yaml` |
|
|
66
|
+
| testing | `app.test.yaml` |
|
|
67
|
+
|
|
68
|
+
Set via `SKRIFT_ENV` environment variable.
|
|
69
|
+
|
|
70
|
+
### app.yaml Structure
|
|
71
|
+
|
|
72
|
+
```yaml
|
|
73
|
+
# Database connection
|
|
74
|
+
db:
|
|
75
|
+
url: $DATABASE_URL # Interpolates from .env
|
|
76
|
+
pool_size: 5
|
|
77
|
+
pool_overflow: 10
|
|
78
|
+
pool_timeout: 30
|
|
79
|
+
echo: false # SQL logging
|
|
80
|
+
|
|
81
|
+
# Authentication
|
|
82
|
+
auth:
|
|
83
|
+
redirect_base_url: "https://example.com"
|
|
84
|
+
allowed_redirect_domains: []
|
|
85
|
+
providers:
|
|
86
|
+
google:
|
|
87
|
+
client_id: $GOOGLE_CLIENT_ID
|
|
88
|
+
client_secret: $GOOGLE_CLIENT_SECRET
|
|
89
|
+
scopes: ["openid", "email", "profile"]
|
|
90
|
+
|
|
91
|
+
# Session cookies
|
|
92
|
+
session:
|
|
93
|
+
cookie_domain: null # null = exact host only
|
|
94
|
+
|
|
95
|
+
# Controllers to load
|
|
96
|
+
controllers:
|
|
97
|
+
- skrift.controllers.auth:AuthController
|
|
98
|
+
- skrift.controllers.web:WebController
|
|
99
|
+
- myapp.controllers:CustomController
|
|
100
|
+
|
|
101
|
+
# Middleware (optional)
|
|
102
|
+
middleware:
|
|
103
|
+
- myapp.middleware:create_logging_middleware
|
|
104
|
+
- factory: myapp.middleware:create_rate_limit
|
|
105
|
+
kwargs:
|
|
106
|
+
requests_per_minute: 100
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
### Settings Class (`skrift/config.py`)
|
|
110
|
+
|
|
111
|
+
```python
|
|
112
|
+
class Settings(BaseSettings):
|
|
113
|
+
debug: bool = False
|
|
114
|
+
secret_key: str # Required - from .env
|
|
115
|
+
|
|
116
|
+
db: DatabaseConfig
|
|
117
|
+
auth: AuthConfig
|
|
118
|
+
session: SessionConfig
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Access settings: `from skrift.config import get_settings`
|
|
122
|
+
|
|
123
|
+
## Database Layer
|
|
124
|
+
|
|
125
|
+
### Base Model
|
|
126
|
+
|
|
127
|
+
All models inherit from `skrift.db.base.Base`:
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
from advanced_alchemy.base import UUIDAuditBase
|
|
131
|
+
|
|
132
|
+
class Base(UUIDAuditBase):
|
|
133
|
+
__abstract__ = True
|
|
134
|
+
# Provides: id (UUID), created_at, updated_at
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
### Session Access
|
|
138
|
+
|
|
139
|
+
Sessions injected via Litestar's SQLAlchemy plugin:
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
143
|
+
|
|
144
|
+
@get("/")
|
|
145
|
+
async def handler(self, db_session: AsyncSession) -> dict:
|
|
146
|
+
# db_session is auto-injected
|
|
147
|
+
...
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Core Models
|
|
151
|
+
|
|
152
|
+
| Model | Table | Purpose |
|
|
153
|
+
|-------|-------|---------|
|
|
154
|
+
| `User` | `users` | User accounts |
|
|
155
|
+
| `OAuthAccount` | `oauth_accounts` | Linked OAuth providers |
|
|
156
|
+
| `Role` | `roles` | Permission roles |
|
|
157
|
+
| `Page` | `pages` | Content pages |
|
|
158
|
+
| `PageRevision` | `page_revisions` | Content history |
|
|
159
|
+
| `Setting` | `settings` | Key-value site settings |
|
|
160
|
+
|
|
161
|
+
### Migrations
|
|
162
|
+
|
|
163
|
+
Uses Alembic via CLI wrapper:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
skrift db upgrade head # Apply all
|
|
167
|
+
skrift db downgrade -1 # Rollback one
|
|
168
|
+
skrift db revision -m "add table" --autogenerate # Generate
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
Migration files in: `skrift/migrations/versions/`
|
|
172
|
+
|
|
173
|
+
## Authentication & Authorization
|
|
174
|
+
|
|
175
|
+
### OAuth Flow
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
/auth/{provider}/login → Provider → /auth/{provider}/callback → Session created
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Providers configured in `app.yaml` under `auth.providers`.
|
|
182
|
+
|
|
183
|
+
### Session Management
|
|
184
|
+
|
|
185
|
+
Client-side encrypted cookies (Litestar's CookieBackendConfig):
|
|
186
|
+
- 7-day expiry
|
|
187
|
+
- HttpOnly, Secure (in production), SameSite=Lax
|
|
188
|
+
|
|
189
|
+
### Role-Based Authorization
|
|
190
|
+
|
|
191
|
+
**Built-in Roles:**
|
|
192
|
+
- `admin`: Full access (`administrator` permission bypasses all checks)
|
|
193
|
+
- `editor`: Can manage pages
|
|
194
|
+
- `author`: Can view drafts
|
|
195
|
+
- `moderator`: Can moderate content
|
|
196
|
+
|
|
197
|
+
**Guard System:**
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
from skrift.auth import auth_guard, Permission, Role
|
|
201
|
+
|
|
202
|
+
# Basic auth required
|
|
203
|
+
guards = [auth_guard]
|
|
204
|
+
|
|
205
|
+
# With permission
|
|
206
|
+
guards = [auth_guard, Permission("manage-pages")]
|
|
207
|
+
|
|
208
|
+
# With role
|
|
209
|
+
guards = [auth_guard, Role("editor")]
|
|
210
|
+
|
|
211
|
+
# Combinations
|
|
212
|
+
guards = [auth_guard, Permission("edit") & Permission("publish")] # AND
|
|
213
|
+
guards = [auth_guard, Role("admin") | Role("editor")] # OR
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
**Custom Roles:**
|
|
217
|
+
|
|
218
|
+
```python
|
|
219
|
+
from skrift.auth import register_role
|
|
220
|
+
|
|
221
|
+
register_role(
|
|
222
|
+
"support",
|
|
223
|
+
"view-tickets",
|
|
224
|
+
"respond-tickets",
|
|
225
|
+
display_name="Support Agent",
|
|
226
|
+
)
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
## Template System
|
|
230
|
+
|
|
231
|
+
### Resolution Order
|
|
232
|
+
|
|
233
|
+
The `Template` class resolves templates from most to least specific:
|
|
234
|
+
|
|
235
|
+
```python
|
|
236
|
+
Template("page", "services", "web")
|
|
237
|
+
# Tries: page-services-web.html → page-services.html → page.html
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Search Directories
|
|
241
|
+
|
|
242
|
+
1. `./templates/` (project root - user overrides)
|
|
243
|
+
2. `skrift/templates/` (package - defaults)
|
|
244
|
+
|
|
245
|
+
### Template Globals
|
|
246
|
+
|
|
247
|
+
Available in all templates:
|
|
248
|
+
- `now()` - Current datetime
|
|
249
|
+
- `site_name()` - From settings
|
|
250
|
+
- `site_tagline()` - From settings
|
|
251
|
+
- `site_copyright_holder()` - From settings
|
|
252
|
+
- `site_copyright_start_year()` - From settings
|
|
253
|
+
|
|
254
|
+
### Template Filters
|
|
255
|
+
|
|
256
|
+
- `markdown` - Render markdown to HTML
|
|
257
|
+
|
|
258
|
+
## Hook/Filter System
|
|
259
|
+
|
|
260
|
+
### Concept
|
|
261
|
+
|
|
262
|
+
WordPress-inspired extensibility:
|
|
263
|
+
- **Actions**: Side effects, no return value
|
|
264
|
+
- **Filters**: Transform and return values
|
|
265
|
+
|
|
266
|
+
### Registration Methods
|
|
267
|
+
|
|
268
|
+
```python
|
|
269
|
+
# Decorator (auto-registers on import)
|
|
270
|
+
@action("hook_name", priority=10)
|
|
271
|
+
async def my_action(arg1, arg2):
|
|
272
|
+
...
|
|
273
|
+
|
|
274
|
+
@filter("hook_name", priority=10)
|
|
275
|
+
async def my_filter(value, arg1) -> Any:
|
|
276
|
+
return modified_value
|
|
277
|
+
|
|
278
|
+
# Direct registration
|
|
279
|
+
hooks.add_action("hook_name", callback, priority=10)
|
|
280
|
+
hooks.add_filter("hook_name", callback, priority=10)
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
### Triggering
|
|
284
|
+
|
|
285
|
+
```python
|
|
286
|
+
# Actions (fire and forget)
|
|
287
|
+
await hooks.do_action("hook_name", arg1, arg2)
|
|
288
|
+
|
|
289
|
+
# Filters (chain transforms)
|
|
290
|
+
result = await hooks.apply_filters("hook_name", initial_value, arg1)
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
### Priority
|
|
294
|
+
|
|
295
|
+
Lower numbers execute first. Default is 10.
|
|
296
|
+
|
|
297
|
+
### Built-in Hook Points
|
|
298
|
+
|
|
299
|
+
**Actions:**
|
|
300
|
+
- `before_page_save(page, is_new)` - Before saving
|
|
301
|
+
- `after_page_save(page, is_new)` - After saving
|
|
302
|
+
- `before_page_delete(page)` - Before deletion
|
|
303
|
+
- `after_page_delete(page)` - After deletion
|
|
304
|
+
|
|
305
|
+
**Filters:**
|
|
306
|
+
- `page_seo_meta(meta, page)` - Modify SEO metadata
|
|
307
|
+
- `page_og_meta(meta, page)` - Modify OpenGraph metadata
|
|
308
|
+
- `sitemap_urls(urls)` - Modify sitemap URLs
|
|
309
|
+
- `sitemap_page(page_data, page)` - Modify sitemap entry
|
|
310
|
+
- `robots_txt(content)` - Modify robots.txt
|
|
311
|
+
- `template_context(context)` - Modify template context
|
|
312
|
+
|
|
313
|
+
## Static Files
|
|
314
|
+
|
|
315
|
+
Served from `/static/` with same priority as templates:
|
|
316
|
+
1. `./static/` (project root - user overrides)
|
|
317
|
+
2. `skrift/static/` (package - defaults)
|
|
318
|
+
|
|
319
|
+
## Error Handling
|
|
320
|
+
|
|
321
|
+
Custom exception handlers in `skrift/lib/exceptions.py`:
|
|
322
|
+
- `HTTPException` → `http_exception_handler`
|
|
323
|
+
- `Exception` → `internal_server_error_handler`
|
|
324
|
+
|
|
325
|
+
Error templates:
|
|
326
|
+
- `error.html` - Generic error
|
|
327
|
+
- `error-404.html` - Not found
|
|
328
|
+
- `error-500.html` - Server error
|
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
# Skrift Code Patterns
|
|
2
|
+
|
|
3
|
+
## Controller Patterns
|
|
4
|
+
|
|
5
|
+
### Basic Controller
|
|
6
|
+
|
|
7
|
+
```python
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from litestar import Controller, get, post
|
|
10
|
+
from litestar.response import Template as TemplateResponse
|
|
11
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
12
|
+
|
|
13
|
+
TEMPLATE_DIR = Path(__file__).parent.parent / "templates"
|
|
14
|
+
|
|
15
|
+
class ItemController(Controller):
|
|
16
|
+
path = "/items"
|
|
17
|
+
|
|
18
|
+
@get("/")
|
|
19
|
+
async def list_items(self, db_session: AsyncSession) -> TemplateResponse:
|
|
20
|
+
items = await item_service.list_items(db_session)
|
|
21
|
+
return TemplateResponse("items/list.html", context={"items": items})
|
|
22
|
+
|
|
23
|
+
@get("/{item_id:uuid}")
|
|
24
|
+
async def get_item(
|
|
25
|
+
self, db_session: AsyncSession, item_id: UUID
|
|
26
|
+
) -> TemplateResponse:
|
|
27
|
+
item = await item_service.get_by_id(db_session, item_id)
|
|
28
|
+
if not item:
|
|
29
|
+
raise NotFoundException(f"Item {item_id} not found")
|
|
30
|
+
return TemplateResponse("items/detail.html", context={"item": item})
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Protected Controller with Guards
|
|
34
|
+
|
|
35
|
+
```python
|
|
36
|
+
from skrift.auth import auth_guard, Permission, Role
|
|
37
|
+
|
|
38
|
+
class AdminController(Controller):
|
|
39
|
+
path = "/admin"
|
|
40
|
+
guards = [auth_guard, Role("admin")]
|
|
41
|
+
|
|
42
|
+
@get("/")
|
|
43
|
+
async def dashboard(self) -> TemplateResponse:
|
|
44
|
+
return TemplateResponse("admin/dashboard.html")
|
|
45
|
+
|
|
46
|
+
@get("/users")
|
|
47
|
+
async def list_users(self, db_session: AsyncSession) -> TemplateResponse:
|
|
48
|
+
# Additional permission check beyond role
|
|
49
|
+
...
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Controller with Request/Session Access
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from litestar import Request
|
|
56
|
+
|
|
57
|
+
class AuthController(Controller):
|
|
58
|
+
path = "/auth"
|
|
59
|
+
|
|
60
|
+
@get("/profile")
|
|
61
|
+
async def profile(
|
|
62
|
+
self, request: Request, db_session: AsyncSession
|
|
63
|
+
) -> TemplateResponse:
|
|
64
|
+
user_id = request.session.get("user_id")
|
|
65
|
+
if not user_id:
|
|
66
|
+
raise NotAuthorizedException()
|
|
67
|
+
|
|
68
|
+
user = await user_service.get_by_id(db_session, UUID(user_id))
|
|
69
|
+
return TemplateResponse("auth/profile.html", context={"user": user})
|
|
70
|
+
|
|
71
|
+
@post("/logout")
|
|
72
|
+
async def logout(self, request: Request) -> Redirect:
|
|
73
|
+
request.session.clear()
|
|
74
|
+
return Redirect("/")
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### Controller with Flash Messages
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
@post("/items")
|
|
81
|
+
async def create_item(
|
|
82
|
+
self, request: Request, db_session: AsyncSession, data: ItemCreate
|
|
83
|
+
) -> Redirect:
|
|
84
|
+
await item_service.create(db_session, data)
|
|
85
|
+
request.session["flash"] = {"type": "success", "message": "Item created!"}
|
|
86
|
+
return Redirect("/items")
|
|
87
|
+
|
|
88
|
+
@get("/items")
|
|
89
|
+
async def list_items(self, request: Request, db_session: AsyncSession):
|
|
90
|
+
flash = request.session.pop("flash", None) # Get and remove
|
|
91
|
+
items = await item_service.list_items(db_session)
|
|
92
|
+
return TemplateResponse("items/list.html", context={"items": items, "flash": flash})
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Database Model Patterns
|
|
96
|
+
|
|
97
|
+
### Basic Model
|
|
98
|
+
|
|
99
|
+
```python
|
|
100
|
+
from sqlalchemy import String, Text, Boolean
|
|
101
|
+
from sqlalchemy.orm import Mapped, mapped_column
|
|
102
|
+
from skrift.db.base import Base
|
|
103
|
+
|
|
104
|
+
class Article(Base):
|
|
105
|
+
__tablename__ = "articles"
|
|
106
|
+
|
|
107
|
+
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
108
|
+
content: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
|
109
|
+
is_published: Mapped[bool] = mapped_column(Boolean, default=False)
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Model with Relationships
|
|
113
|
+
|
|
114
|
+
```python
|
|
115
|
+
from sqlalchemy import ForeignKey
|
|
116
|
+
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
117
|
+
from uuid import UUID
|
|
118
|
+
|
|
119
|
+
class Comment(Base):
|
|
120
|
+
__tablename__ = "comments"
|
|
121
|
+
|
|
122
|
+
# Foreign key
|
|
123
|
+
article_id: Mapped[UUID] = mapped_column(
|
|
124
|
+
ForeignKey("articles.id", ondelete="CASCADE"),
|
|
125
|
+
nullable=False,
|
|
126
|
+
index=True,
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# Relationship
|
|
130
|
+
article: Mapped["Article"] = relationship("Article", back_populates="comments")
|
|
131
|
+
|
|
132
|
+
content: Mapped[str] = mapped_column(Text, nullable=False)
|
|
133
|
+
|
|
134
|
+
# In Article model:
|
|
135
|
+
class Article(Base):
|
|
136
|
+
# ...
|
|
137
|
+
comments: Mapped[list["Comment"]] = relationship(
|
|
138
|
+
"Comment",
|
|
139
|
+
back_populates="article",
|
|
140
|
+
cascade="all, delete-orphan",
|
|
141
|
+
order_by="desc(Comment.created_at)",
|
|
142
|
+
)
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
### Model with Optional Fields
|
|
146
|
+
|
|
147
|
+
```python
|
|
148
|
+
from datetime import datetime
|
|
149
|
+
|
|
150
|
+
class Event(Base):
|
|
151
|
+
__tablename__ = "events"
|
|
152
|
+
|
|
153
|
+
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
154
|
+
# Optional fields use | None
|
|
155
|
+
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
156
|
+
starts_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
### Model with Indexes
|
|
160
|
+
|
|
161
|
+
```python
|
|
162
|
+
class LogEntry(Base):
|
|
163
|
+
__tablename__ = "log_entries"
|
|
164
|
+
|
|
165
|
+
level: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
|
166
|
+
message: Mapped[str] = mapped_column(Text, nullable=False)
|
|
167
|
+
# Composite index
|
|
168
|
+
__table_args__ = (
|
|
169
|
+
Index("ix_log_entries_level_created", "level", "created_at"),
|
|
170
|
+
)
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
## Service Layer Patterns
|
|
174
|
+
|
|
175
|
+
### Basic CRUD Service
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
from sqlalchemy import select
|
|
179
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
180
|
+
from uuid import UUID
|
|
181
|
+
|
|
182
|
+
async def list_items(
|
|
183
|
+
db_session: AsyncSession,
|
|
184
|
+
limit: int | None = None,
|
|
185
|
+
offset: int = 0,
|
|
186
|
+
) -> list[Item]:
|
|
187
|
+
query = select(Item).order_by(Item.created_at.desc())
|
|
188
|
+
if offset:
|
|
189
|
+
query = query.offset(offset)
|
|
190
|
+
if limit:
|
|
191
|
+
query = query.limit(limit)
|
|
192
|
+
result = await db_session.execute(query)
|
|
193
|
+
return list(result.scalars().all())
|
|
194
|
+
|
|
195
|
+
async def get_by_id(db_session: AsyncSession, item_id: UUID) -> Item | None:
|
|
196
|
+
result = await db_session.execute(select(Item).where(Item.id == item_id))
|
|
197
|
+
return result.scalar_one_or_none()
|
|
198
|
+
|
|
199
|
+
async def create(db_session: AsyncSession, name: str, **kwargs) -> Item:
|
|
200
|
+
item = Item(name=name, **kwargs)
|
|
201
|
+
db_session.add(item)
|
|
202
|
+
await db_session.commit()
|
|
203
|
+
await db_session.refresh(item)
|
|
204
|
+
return item
|
|
205
|
+
|
|
206
|
+
async def update(
|
|
207
|
+
db_session: AsyncSession,
|
|
208
|
+
item_id: UUID,
|
|
209
|
+
**updates,
|
|
210
|
+
) -> Item | None:
|
|
211
|
+
item = await get_by_id(db_session, item_id)
|
|
212
|
+
if not item:
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
for key, value in updates.items():
|
|
216
|
+
if value is not None:
|
|
217
|
+
setattr(item, key, value)
|
|
218
|
+
|
|
219
|
+
await db_session.commit()
|
|
220
|
+
await db_session.refresh(item)
|
|
221
|
+
return item
|
|
222
|
+
|
|
223
|
+
async def delete(db_session: AsyncSession, item_id: UUID) -> bool:
|
|
224
|
+
item = await get_by_id(db_session, item_id)
|
|
225
|
+
if not item:
|
|
226
|
+
return False
|
|
227
|
+
await db_session.delete(item)
|
|
228
|
+
await db_session.commit()
|
|
229
|
+
return True
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
### Service with Filtering
|
|
233
|
+
|
|
234
|
+
```python
|
|
235
|
+
from sqlalchemy import select, and_, or_
|
|
236
|
+
from datetime import datetime, UTC
|
|
237
|
+
|
|
238
|
+
async def list_published_articles(
|
|
239
|
+
db_session: AsyncSession,
|
|
240
|
+
category: str | None = None,
|
|
241
|
+
search: str | None = None,
|
|
242
|
+
) -> list[Article]:
|
|
243
|
+
query = select(Article).where(Article.is_published == True)
|
|
244
|
+
|
|
245
|
+
filters = []
|
|
246
|
+
if category:
|
|
247
|
+
filters.append(Article.category == category)
|
|
248
|
+
if search:
|
|
249
|
+
filters.append(
|
|
250
|
+
or_(
|
|
251
|
+
Article.title.ilike(f"%{search}%"),
|
|
252
|
+
Article.content.ilike(f"%{search}%"),
|
|
253
|
+
)
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
if filters:
|
|
257
|
+
query = query.where(and_(*filters))
|
|
258
|
+
|
|
259
|
+
query = query.order_by(Article.published_at.desc())
|
|
260
|
+
result = await db_session.execute(query)
|
|
261
|
+
return list(result.scalars().all())
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
### Service with Hooks
|
|
265
|
+
|
|
266
|
+
```python
|
|
267
|
+
from skrift.lib.hooks import hooks
|
|
268
|
+
|
|
269
|
+
BEFORE_ITEM_SAVE = "before_item_save"
|
|
270
|
+
AFTER_ITEM_SAVE = "after_item_save"
|
|
271
|
+
|
|
272
|
+
async def create(db_session: AsyncSession, name: str) -> Item:
|
|
273
|
+
item = Item(name=name)
|
|
274
|
+
|
|
275
|
+
await hooks.do_action(BEFORE_ITEM_SAVE, item, is_new=True)
|
|
276
|
+
|
|
277
|
+
db_session.add(item)
|
|
278
|
+
await db_session.commit()
|
|
279
|
+
await db_session.refresh(item)
|
|
280
|
+
|
|
281
|
+
await hooks.do_action(AFTER_ITEM_SAVE, item, is_new=True)
|
|
282
|
+
|
|
283
|
+
return item
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
## Hook/Filter Patterns
|
|
287
|
+
|
|
288
|
+
### Action Hook
|
|
289
|
+
|
|
290
|
+
```python
|
|
291
|
+
from skrift.lib.hooks import action
|
|
292
|
+
|
|
293
|
+
@action("after_page_save", priority=10)
|
|
294
|
+
async def invalidate_cache(page, is_new: bool):
|
|
295
|
+
"""Clear cache when page is saved."""
|
|
296
|
+
cache.delete(f"page:{page.slug}")
|
|
297
|
+
|
|
298
|
+
@action("after_user_register", priority=5)
|
|
299
|
+
async def send_welcome_email(user):
|
|
300
|
+
"""Send welcome email to new users."""
|
|
301
|
+
await email_service.send_welcome(user.email)
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### Filter Hook
|
|
305
|
+
|
|
306
|
+
```python
|
|
307
|
+
from skrift.lib.hooks import filter
|
|
308
|
+
|
|
309
|
+
@filter("page_seo_meta", priority=10)
|
|
310
|
+
async def add_default_author(meta: dict, page) -> dict:
|
|
311
|
+
"""Add default author to SEO meta."""
|
|
312
|
+
if "author" not in meta:
|
|
313
|
+
meta["author"] = "Site Author"
|
|
314
|
+
return meta
|
|
315
|
+
|
|
316
|
+
@filter("template_context", priority=20)
|
|
317
|
+
async def add_global_vars(context: dict) -> dict:
|
|
318
|
+
"""Add global variables to all templates."""
|
|
319
|
+
context["current_year"] = datetime.now().year
|
|
320
|
+
context["version"] = "1.0.0"
|
|
321
|
+
return context
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
### Custom Hook Points
|
|
325
|
+
|
|
326
|
+
```python
|
|
327
|
+
# Define hook constants
|
|
328
|
+
MY_BEFORE_SAVE = "my_before_save"
|
|
329
|
+
MY_AFTER_SAVE = "my_after_save"
|
|
330
|
+
MY_DATA_FILTER = "my_data_filter"
|
|
331
|
+
|
|
332
|
+
# Trigger in service
|
|
333
|
+
async def save_thing(db_session: AsyncSession, data: dict) -> Thing:
|
|
334
|
+
# Apply filters to data
|
|
335
|
+
data = await hooks.apply_filters(MY_DATA_FILTER, data)
|
|
336
|
+
|
|
337
|
+
thing = Thing(**data)
|
|
338
|
+
await hooks.do_action(MY_BEFORE_SAVE, thing)
|
|
339
|
+
|
|
340
|
+
db_session.add(thing)
|
|
341
|
+
await db_session.commit()
|
|
342
|
+
|
|
343
|
+
await hooks.do_action(MY_AFTER_SAVE, thing)
|
|
344
|
+
return thing
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
## Template Patterns
|
|
348
|
+
|
|
349
|
+
### Using Template Class
|
|
350
|
+
|
|
351
|
+
```python
|
|
352
|
+
from skrift.lib.template import Template
|
|
353
|
+
from pathlib import Path
|
|
354
|
+
|
|
355
|
+
TEMPLATE_DIR = Path(__file__).parent.parent / "templates"
|
|
356
|
+
|
|
357
|
+
@get("/{slug:str}")
|
|
358
|
+
async def view_item(self, db_session: AsyncSession, slug: str) -> TemplateResponse:
|
|
359
|
+
item = await item_service.get_by_slug(db_session, slug)
|
|
360
|
+
|
|
361
|
+
# Tries: item-{slug}.html -> item.html
|
|
362
|
+
template = Template("item", slug, context={"item": item})
|
|
363
|
+
return template.render(TEMPLATE_DIR)
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
### Template with SEO Context
|
|
367
|
+
|
|
368
|
+
```python
|
|
369
|
+
from skrift.lib.seo import get_page_seo_meta, get_page_og_meta
|
|
370
|
+
|
|
371
|
+
@get("/{slug:path}")
|
|
372
|
+
async def view_page(self, request: Request, db_session: AsyncSession, slug: str):
|
|
373
|
+
page = await page_service.get_by_slug(db_session, slug)
|
|
374
|
+
|
|
375
|
+
site_name = get_cached_site_name()
|
|
376
|
+
base_url = str(request.base_url).rstrip("/")
|
|
377
|
+
|
|
378
|
+
seo_meta = await get_page_seo_meta(page, site_name, base_url)
|
|
379
|
+
og_meta = await get_page_og_meta(page, site_name, base_url)
|
|
380
|
+
|
|
381
|
+
return TemplateResponse("page.html", context={
|
|
382
|
+
"page": page,
|
|
383
|
+
"seo_meta": seo_meta,
|
|
384
|
+
"og_meta": og_meta,
|
|
385
|
+
})
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
## Middleware Patterns
|
|
389
|
+
|
|
390
|
+
### Simple Middleware
|
|
391
|
+
|
|
392
|
+
```python
|
|
393
|
+
# myapp/middleware.py
|
|
394
|
+
from litestar.middleware import AbstractMiddleware
|
|
395
|
+
from litestar.types import ASGIApp, Receive, Scope, Send
|
|
396
|
+
|
|
397
|
+
class LoggingMiddleware(AbstractMiddleware):
|
|
398
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
399
|
+
if scope["type"] == "http":
|
|
400
|
+
print(f"Request: {scope['method']} {scope['path']}")
|
|
401
|
+
await self.app(scope, receive, send)
|
|
402
|
+
|
|
403
|
+
def create_logging_middleware(app: ASGIApp) -> ASGIApp:
|
|
404
|
+
return LoggingMiddleware(app=app)
|
|
405
|
+
```
|
|
406
|
+
|
|
407
|
+
Register in app.yaml:
|
|
408
|
+
```yaml
|
|
409
|
+
middleware:
|
|
410
|
+
- myapp.middleware:create_logging_middleware
|
|
411
|
+
```
|
|
412
|
+
|
|
413
|
+
### Middleware with Configuration
|
|
414
|
+
|
|
415
|
+
```python
|
|
416
|
+
from litestar.middleware import DefineMiddleware
|
|
417
|
+
|
|
418
|
+
class RateLimitMiddleware(AbstractMiddleware):
|
|
419
|
+
def __init__(self, app: ASGIApp, requests_per_minute: int = 60):
|
|
420
|
+
super().__init__(app)
|
|
421
|
+
self.limit = requests_per_minute
|
|
422
|
+
|
|
423
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
424
|
+
# Rate limiting logic...
|
|
425
|
+
await self.app(scope, receive, send)
|
|
426
|
+
|
|
427
|
+
def create_rate_limit_middleware(app: ASGIApp, requests_per_minute: int = 60) -> ASGIApp:
|
|
428
|
+
return RateLimitMiddleware(app, requests_per_minute)
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
Register with kwargs:
|
|
432
|
+
```yaml
|
|
433
|
+
middleware:
|
|
434
|
+
- factory: myapp.middleware:create_rate_limit_middleware
|
|
435
|
+
kwargs:
|
|
436
|
+
requests_per_minute: 100
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
## Authorization Patterns
|
|
440
|
+
|
|
441
|
+
### Permission-Based Access
|
|
442
|
+
|
|
443
|
+
```python
|
|
444
|
+
from skrift.auth import auth_guard, Permission
|
|
445
|
+
|
|
446
|
+
class ArticleController(Controller):
|
|
447
|
+
path = "/articles"
|
|
448
|
+
guards = [auth_guard] # All routes require auth
|
|
449
|
+
|
|
450
|
+
@get("/")
|
|
451
|
+
async def list_articles(self, db_session: AsyncSession):
|
|
452
|
+
# Anyone authenticated can list
|
|
453
|
+
...
|
|
454
|
+
|
|
455
|
+
@post("/", guards=[Permission("create-articles")])
|
|
456
|
+
async def create_article(self, db_session: AsyncSession, data: ArticleCreate):
|
|
457
|
+
# Only users with create-articles permission
|
|
458
|
+
...
|
|
459
|
+
|
|
460
|
+
@delete("/{id:uuid}", guards=[Permission("delete-articles")])
|
|
461
|
+
async def delete_article(self, db_session: AsyncSession, id: UUID):
|
|
462
|
+
# Only users with delete-articles permission
|
|
463
|
+
...
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
### Custom Role Registration
|
|
467
|
+
|
|
468
|
+
```python
|
|
469
|
+
# In your app's __init__.py or startup module
|
|
470
|
+
from skrift.auth import register_role
|
|
471
|
+
|
|
472
|
+
# Register before database sync (app startup)
|
|
473
|
+
register_role(
|
|
474
|
+
"contributor",
|
|
475
|
+
"create-articles",
|
|
476
|
+
"edit-own-articles",
|
|
477
|
+
display_name="Contributor",
|
|
478
|
+
description="Can create and edit their own articles",
|
|
479
|
+
)
|
|
480
|
+
|
|
481
|
+
register_role(
|
|
482
|
+
"reviewer",
|
|
483
|
+
"view-drafts",
|
|
484
|
+
"approve-articles",
|
|
485
|
+
display_name="Reviewer",
|
|
486
|
+
description="Can review and approve articles",
|
|
487
|
+
)
|
|
488
|
+
```
|
|
489
|
+
|
|
490
|
+
## Testing Patterns
|
|
491
|
+
|
|
492
|
+
### Controller Test
|
|
493
|
+
|
|
494
|
+
```python
|
|
495
|
+
import pytest
|
|
496
|
+
from litestar.testing import TestClient
|
|
497
|
+
|
|
498
|
+
@pytest.fixture
|
|
499
|
+
def client(app):
|
|
500
|
+
return TestClient(app)
|
|
501
|
+
|
|
502
|
+
async def test_list_items(client, db_session):
|
|
503
|
+
# Create test data
|
|
504
|
+
item = await item_service.create(db_session, name="Test")
|
|
505
|
+
|
|
506
|
+
response = client.get("/items")
|
|
507
|
+
assert response.status_code == 200
|
|
508
|
+
assert "Test" in response.text
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
### Service Test
|
|
512
|
+
|
|
513
|
+
```python
|
|
514
|
+
import pytest
|
|
515
|
+
|
|
516
|
+
async def test_create_item(db_session):
|
|
517
|
+
item = await item_service.create(db_session, name="Test Item")
|
|
518
|
+
|
|
519
|
+
assert item.id is not None
|
|
520
|
+
assert item.name == "Test Item"
|
|
521
|
+
|
|
522
|
+
async def test_list_items_filters(db_session):
|
|
523
|
+
await item_service.create(db_session, name="Item A", published=True)
|
|
524
|
+
await item_service.create(db_session, name="Item B", published=False)
|
|
525
|
+
|
|
526
|
+
published = await item_service.list_items(db_session, published_only=True)
|
|
527
|
+
|
|
528
|
+
assert len(published) == 1
|
|
529
|
+
assert published[0].name == "Item A"
|
|
530
|
+
```
|
|
531
|
+
|
|
532
|
+
### Hook Test
|
|
533
|
+
|
|
534
|
+
```python
|
|
535
|
+
from skrift.lib.hooks import hooks
|
|
536
|
+
|
|
537
|
+
async def test_hook_called(db_session):
|
|
538
|
+
called_with = []
|
|
539
|
+
|
|
540
|
+
async def track_save(page, is_new):
|
|
541
|
+
called_with.append((page.title, is_new))
|
|
542
|
+
|
|
543
|
+
hooks.add_action("after_page_save", track_save)
|
|
544
|
+
|
|
545
|
+
try:
|
|
546
|
+
await page_service.create(db_session, slug="test", title="Test")
|
|
547
|
+
|
|
548
|
+
assert len(called_with) == 1
|
|
549
|
+
assert called_with[0] == ("Test", True)
|
|
550
|
+
finally:
|
|
551
|
+
hooks.remove_action("after_page_save", track_save)
|
|
552
|
+
```
|
skrift/cli.py
CHANGED
|
@@ -139,5 +139,52 @@ def db(ctx):
|
|
|
139
139
|
sys.exit(alembic_main(alembic_argv))
|
|
140
140
|
|
|
141
141
|
|
|
142
|
+
@cli.command("init-claude")
|
|
143
|
+
@click.option(
|
|
144
|
+
"--force",
|
|
145
|
+
is_flag=True,
|
|
146
|
+
help="Overwrite existing skill files",
|
|
147
|
+
)
|
|
148
|
+
def init_claude(force):
|
|
149
|
+
"""Set up Claude Code skill for Skrift development.
|
|
150
|
+
|
|
151
|
+
Copies the Skrift skill files to .claude/skills/skrift/ in the current
|
|
152
|
+
directory, enabling Claude Code to understand Skrift conventions.
|
|
153
|
+
|
|
154
|
+
\b
|
|
155
|
+
Creates:
|
|
156
|
+
.claude/skills/skrift/SKILL.md - Main skill with dynamic context
|
|
157
|
+
.claude/skills/skrift/architecture.md - System architecture docs
|
|
158
|
+
.claude/skills/skrift/patterns.md - Code patterns and examples
|
|
159
|
+
"""
|
|
160
|
+
import importlib.resources
|
|
161
|
+
|
|
162
|
+
skill_dir = Path.cwd() / ".claude" / "skills" / "skrift"
|
|
163
|
+
|
|
164
|
+
# Check if skill already exists
|
|
165
|
+
if skill_dir.exists() and not force:
|
|
166
|
+
click.echo(f"Skill directory already exists: {skill_dir}", err=True)
|
|
167
|
+
click.echo("Use --force to overwrite existing files.", err=True)
|
|
168
|
+
sys.exit(1)
|
|
169
|
+
|
|
170
|
+
# Create directory
|
|
171
|
+
skill_dir.mkdir(parents=True, exist_ok=True)
|
|
172
|
+
|
|
173
|
+
# Copy skill files from package
|
|
174
|
+
skill_files = ["SKILL.md", "architecture.md", "patterns.md"]
|
|
175
|
+
package_files = importlib.resources.files("skrift.claude_skill")
|
|
176
|
+
|
|
177
|
+
for filename in skill_files:
|
|
178
|
+
source = package_files.joinpath(filename)
|
|
179
|
+
dest = skill_dir / filename
|
|
180
|
+
|
|
181
|
+
content = source.read_text()
|
|
182
|
+
dest.write_text(content)
|
|
183
|
+
click.echo(f"Created {dest.relative_to(Path.cwd())}")
|
|
184
|
+
|
|
185
|
+
click.echo()
|
|
186
|
+
click.echo("Claude Code skill installed. Use /skrift to activate.")
|
|
187
|
+
|
|
188
|
+
|
|
142
189
|
if __name__ == "__main__":
|
|
143
190
|
cli()
|
skrift/config.py
CHANGED
skrift/db/session.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Safe SQLAlchemy async session provider with CancelledError handling.
|
|
2
|
+
|
|
3
|
+
This module provides a custom session configuration that properly handles
|
|
4
|
+
connection cleanup when HTTP requests are cancelled (client disconnect, timeout).
|
|
5
|
+
Without this, CancelledError can prevent session cleanup, leading to connection
|
|
6
|
+
pool leaks.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from collections.abc import AsyncGenerator
|
|
11
|
+
from typing import TYPE_CHECKING, Callable, cast
|
|
12
|
+
|
|
13
|
+
from advanced_alchemy._listeners import set_async_context
|
|
14
|
+
from advanced_alchemy.extensions.litestar import SQLAlchemyAsyncConfig
|
|
15
|
+
from advanced_alchemy.extensions.litestar._utils import (
|
|
16
|
+
delete_aa_scope_state,
|
|
17
|
+
get_aa_scope_state,
|
|
18
|
+
set_aa_scope_state,
|
|
19
|
+
)
|
|
20
|
+
from sqlalchemy.ext.asyncio import AsyncSession
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from litestar.datastructures import State
|
|
24
|
+
from litestar.types import Scope
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class SafeSQLAlchemyAsyncConfig(SQLAlchemyAsyncConfig):
|
|
28
|
+
"""SQLAlchemy async config with safe session cleanup on request cancellation.
|
|
29
|
+
|
|
30
|
+
This subclass overrides `provide_session` to use an async generator that
|
|
31
|
+
catches CancelledError and ensures sessions are properly closed, preventing
|
|
32
|
+
connection pool leaks when HTTP requests are cancelled.
|
|
33
|
+
|
|
34
|
+
The standard advanced_alchemy session management relies on ASGI events
|
|
35
|
+
(http.response.body, http.disconnect) to trigger cleanup via before_send_handler.
|
|
36
|
+
However, CancelledError can prevent these events from firing, leaving sessions
|
|
37
|
+
in an unclosed state.
|
|
38
|
+
|
|
39
|
+
By using an async generator, Litestar's dependency injection system ensures
|
|
40
|
+
cleanup runs even when CancelledError is raised.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
async def provide_session(
|
|
44
|
+
self,
|
|
45
|
+
state: "State",
|
|
46
|
+
scope: "Scope",
|
|
47
|
+
) -> AsyncGenerator[AsyncSession, None]:
|
|
48
|
+
"""Provide a database session with proper cleanup on cancellation.
|
|
49
|
+
|
|
50
|
+
This async generator wraps session creation to ensure that
|
|
51
|
+
CancelledError (raised when an HTTP request is cancelled) doesn't
|
|
52
|
+
prevent session cleanup, which would leak connections.
|
|
53
|
+
|
|
54
|
+
Args:
|
|
55
|
+
state: The application state
|
|
56
|
+
scope: The ASGI scope
|
|
57
|
+
|
|
58
|
+
Yields:
|
|
59
|
+
AsyncSession: The database session
|
|
60
|
+
"""
|
|
61
|
+
# Check if we already have a session in scope
|
|
62
|
+
session = cast(
|
|
63
|
+
"AsyncSession | None",
|
|
64
|
+
get_aa_scope_state(scope, self.session_scope_key),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
if session is None:
|
|
68
|
+
# Create a new session
|
|
69
|
+
session_maker = cast(
|
|
70
|
+
"Callable[[], AsyncSession]",
|
|
71
|
+
state[self.session_maker_app_state_key],
|
|
72
|
+
)
|
|
73
|
+
session = session_maker()
|
|
74
|
+
# Store in scope for reuse within this request
|
|
75
|
+
set_aa_scope_state(scope, self.session_scope_key, session)
|
|
76
|
+
|
|
77
|
+
set_async_context(True)
|
|
78
|
+
|
|
79
|
+
try:
|
|
80
|
+
yield session
|
|
81
|
+
except asyncio.CancelledError:
|
|
82
|
+
# Request was cancelled - ensure we clean up the session
|
|
83
|
+
# This prevents connection pool leaks
|
|
84
|
+
await session.close()
|
|
85
|
+
# Remove the session from scope state to prevent double-close
|
|
86
|
+
delete_aa_scope_state(scope, self.session_scope_key)
|
|
87
|
+
raise
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
__all__ = ["SafeSQLAlchemyAsyncConfig"]
|
skrift/setup/config_writer.py
CHANGED
skrift/setup/state.py
CHANGED
|
@@ -172,9 +172,9 @@ def run_migrations_if_needed() -> tuple[bool, str | None]:
|
|
|
172
172
|
return True, None
|
|
173
173
|
|
|
174
174
|
try:
|
|
175
|
-
# Try skrift
|
|
175
|
+
# Try skrift db first (the correct command)
|
|
176
176
|
result = subprocess.run(
|
|
177
|
-
["skrift
|
|
177
|
+
["skrift", "db", "upgrade", "head"],
|
|
178
178
|
capture_output=True,
|
|
179
179
|
text=True,
|
|
180
180
|
cwd=Path.cwd(),
|
|
@@ -183,7 +183,7 @@ def run_migrations_if_needed() -> tuple[bool, str | None]:
|
|
|
183
183
|
if result.returncode == 0:
|
|
184
184
|
_migrations_run = True
|
|
185
185
|
return True, None
|
|
186
|
-
# If skrift
|
|
186
|
+
# If skrift db fails, try alembic directly
|
|
187
187
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
|
188
188
|
pass
|
|
189
189
|
|
|
@@ -202,7 +202,7 @@ def run_migrations_if_needed() -> tuple[bool, str | None]:
|
|
|
202
202
|
except subprocess.TimeoutExpired:
|
|
203
203
|
return False, "Migration timed out"
|
|
204
204
|
except FileNotFoundError:
|
|
205
|
-
return False, "
|
|
205
|
+
return False, "skrift db command not found"
|
|
206
206
|
except Exception as e:
|
|
207
207
|
return False, str(e)
|
|
208
208
|
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
skrift/__init__.py,sha256=eXE5PFVkJpH5XsV_ZlrTIeFPUPrmcHYAj4GpRS3R5PY,29
|
|
2
2
|
skrift/__main__.py,sha256=wt6JZL9nBhKU36vdyurhOEtWy7w3C9zohyy24PLcKho,164
|
|
3
3
|
skrift/alembic.ini,sha256=mYguI6CbMCTyfHctsGiTyf9Z5gv21FdeI3qtfgOHO3A,1815
|
|
4
|
-
skrift/asgi.py,sha256=
|
|
5
|
-
skrift/cli.py,sha256
|
|
6
|
-
skrift/config.py,sha256=
|
|
4
|
+
skrift/asgi.py,sha256=re_DzFiLzFdSiY3TAj_UGuVedkZTIs8jx1rHb6vPvkM,23509
|
|
5
|
+
skrift/cli.py,sha256=-K-uO1r8y5r8wsRGygZFyaH1DsY1s9L0XViScPBnt9s,5744
|
|
6
|
+
skrift/config.py,sha256=9qDy9GhGbUZWinsagfPPkeAWsapHoZmqmeYFR3CEdxQ,7928
|
|
7
7
|
skrift/admin/__init__.py,sha256=x81Cj_ilVmv6slaMl16HHyT_AgrnLxKEWkS0RPa4V9s,289
|
|
8
8
|
skrift/admin/controller.py,sha256=gSprerjhxqxaH2lHm8vbLuz-jslN553FpbKF7jwEm9Y,19607
|
|
9
9
|
skrift/admin/navigation.py,sha256=VwttFoIUIJy5rONKIkJd5w4CNkUpeK22_OfLGHecN34,3382
|
|
@@ -24,12 +24,17 @@ skrift/auth/__init__.py,sha256=uHMqty3dgDSYlReVT96WhygzH6qNAWSVDWoxumzxmsA,1155
|
|
|
24
24
|
skrift/auth/guards.py,sha256=QePajHsGnJ4R_hlhzblr5IoAgZcY5jzeZ64bJwDL9hM,4451
|
|
25
25
|
skrift/auth/roles.py,sha256=Cvwv8v7Li6CJHhucTuzxRjaNmS530F9sbbQlCPRqhm4,3291
|
|
26
26
|
skrift/auth/services.py,sha256=h6GTXdN5UMRYglnaFz4asMoutVkSSAyL3_Vt56N26pA,5441
|
|
27
|
+
skrift/claude_skill/SKILL.md,sha256=cOEBAsUSw224op9XagsRGpv1rMBswD23ZhfsPgETLOs,5972
|
|
28
|
+
skrift/claude_skill/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
29
|
+
skrift/claude_skill/architecture.md,sha256=jKtegv3FsWmeWsW0bmRr6tQMVlbFt-y_wSTRVn90qtc,8508
|
|
30
|
+
skrift/claude_skill/patterns.md,sha256=YjbBgozeUpDYx44PazDXY8r6bTWddvUiZ7ub6Ol7qtc,14415
|
|
27
31
|
skrift/controllers/__init__.py,sha256=bVr0tsSGz7jBi002Lqd1AA1FQd7ZA_IagsqTKpiHiK0,147
|
|
28
32
|
skrift/controllers/auth.py,sha256=ohYjBJiRa5RDQKUQjL3nR6lzm4uUZcfIgnF2G-QY5o8,21905
|
|
29
33
|
skrift/controllers/sitemap.py,sha256=Yfz2ElHrkZVMhAN5ixriU5RwEFJ713rS0Uezj90Bfts,3814
|
|
30
34
|
skrift/controllers/web.py,sha256=J-kbmzwJcKfiBvNbF8yR7GHGo0jhvWzY77YstJi_pcI,2968
|
|
31
35
|
skrift/db/__init__.py,sha256=uSghyDFT2K4SFiEqUzdjCGzWpS-Oy6Sd1FUappau-v0,52
|
|
32
36
|
skrift/db/base.py,sha256=QJplFj9235kZdScASEpvyNHln6YW2hqbHwJEYZ3OSsc,173
|
|
37
|
+
skrift/db/session.py,sha256=b2E4VeSIscPTEDBWQrketJbmUqLdWqdhqhUv7cojUkY,3237
|
|
33
38
|
skrift/db/models/__init__.py,sha256=DtgYPLs8rTLNFmZ0-HzD_8uhvaGRtayZCrxpMhAo88U,413
|
|
34
39
|
skrift/db/models/oauth_account.py,sha256=fzmApdtrRYeEWLl35Y7H0YtF7CWjzrpG-lknBOuYRc8,1840
|
|
35
40
|
skrift/db/models/page.py,sha256=pYMWBmQ7FfDiFcyBU3xV4kbpWboimZw5TZtVDOcr73o,2068
|
|
@@ -50,11 +55,11 @@ skrift/lib/markdown.py,sha256=fPLUIAsmFXC64Os2RngQG7P2xRKBd1RO8fh7b5_gV1U,950
|
|
|
50
55
|
skrift/lib/seo.py,sha256=3fAfXn_7Jpq_OhINpcbQznixLcJTbaD-I86_Je2SFoo,2512
|
|
51
56
|
skrift/lib/template.py,sha256=4_urkRfvth75yNeQ5TyGTHvkvs3vVef7TcwZx0k285k,4226
|
|
52
57
|
skrift/setup/__init__.py,sha256=3VjFPMES5y0M5cQ9R4C1xazqiEPEDqTPjX9-3rBMXnA,478
|
|
53
|
-
skrift/setup/config_writer.py,sha256=
|
|
58
|
+
skrift/setup/config_writer.py,sha256=2yXwJ5TRcRnQAiRL2i0cyJ3KYnJJ-QUNUsCuSOCR7M0,6378
|
|
54
59
|
skrift/setup/controller.py,sha256=v0Ey8T7ptJ5A3vOqQ1TUAXH1bQwA0288J5uyUWMihsw,34250
|
|
55
60
|
skrift/setup/middleware.py,sha256=Nai8ZG2vHldngmAhq7kWzAwKRNcP5tHKhJHa5dCh404,2941
|
|
56
61
|
skrift/setup/providers.py,sha256=0BFKB6168NcmtXxFF6ofHgEDMQD2FbXkexsqrARVtDI,7967
|
|
57
|
-
skrift/setup/state.py,sha256=
|
|
62
|
+
skrift/setup/state.py,sha256=3RJzdtRY-ZtTWsb9aLTw43lEvgnanXMAEhK7QQmaAU0,9321
|
|
58
63
|
skrift/static/css/style.css,sha256=6in8KjmwythfQV9pGt-uSCJCoHG1OijrdDOOad7oYBk,22978
|
|
59
64
|
skrift/templates/base.html,sha256=s6gylukPyBvb9StgNz8MRZr4h3QT3C8GTMiwOu23nC0,4421
|
|
60
65
|
skrift/templates/error-404.html,sha256=sJrDaF3Or3Nyki8mxo3wBxLLzgy4wkB9p9wdS8pRA6k,409
|
|
@@ -80,7 +85,7 @@ skrift/templates/setup/configuring.html,sha256=2KHW9h2BrJgL_kO5IizbAYs4pnFLyRf76
|
|
|
80
85
|
skrift/templates/setup/database.html,sha256=gU4-315-QraHa2Eq4Fh3b55QpOM2CkJzh27_Yz13frA,5495
|
|
81
86
|
skrift/templates/setup/restart.html,sha256=GHg31F_e2uLFhWUzJoalk0Y0oYLqsFWyZXWKX3mblbY,1355
|
|
82
87
|
skrift/templates/setup/site.html,sha256=PSOH-q1-ZBl47iSW9-Ad6lEfJn_fzdGD3Pk4vb3xgK4,1680
|
|
83
|
-
skrift-0.1.
|
|
84
|
-
skrift-0.1.
|
|
85
|
-
skrift-0.1.
|
|
86
|
-
skrift-0.1.
|
|
88
|
+
skrift-0.1.0a17.dist-info/METADATA,sha256=TElTCDYHVD_CoTOzfB9htMyL2UfBHX2qYs28IIp3zt4,7267
|
|
89
|
+
skrift-0.1.0a17.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
90
|
+
skrift-0.1.0a17.dist-info/entry_points.txt,sha256=uquZ5Mumqr0xwYTpTcNiJtFSITGfF6_QCCy2DZJSZig,42
|
|
91
|
+
skrift-0.1.0a17.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|