skrift 0.1.0a15__py3-none-any.whl → 0.1.0a16__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/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-0.1.0a15.dist-info → skrift-0.1.0a16.dist-info}/METADATA +1 -1
- {skrift-0.1.0a15.dist-info → skrift-0.1.0a16.dist-info}/RECORD +9 -5
- {skrift-0.1.0a15.dist-info → skrift-0.1.0a16.dist-info}/WHEEL +0 -0
- {skrift-0.1.0a15.dist-info → skrift-0.1.0a16.dist-info}/entry_points.txt +0 -0
|
@@ -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()
|
|
@@ -2,7 +2,7 @@ skrift/__init__.py,sha256=eXE5PFVkJpH5XsV_ZlrTIeFPUPrmcHYAj4GpRS3R5PY,29
|
|
|
2
2
|
skrift/__main__.py,sha256=wt6JZL9nBhKU36vdyurhOEtWy7w3C9zohyy24PLcKho,164
|
|
3
3
|
skrift/alembic.ini,sha256=mYguI6CbMCTyfHctsGiTyf9Z5gv21FdeI3qtfgOHO3A,1815
|
|
4
4
|
skrift/asgi.py,sha256=7uPvgsGMpZJyPOjIGxL9QlXTRLWh9uRAhcg-RdBS1c8,23355
|
|
5
|
-
skrift/cli.py,sha256
|
|
5
|
+
skrift/cli.py,sha256=-K-uO1r8y5r8wsRGygZFyaH1DsY1s9L0XViScPBnt9s,5744
|
|
6
6
|
skrift/config.py,sha256=ZDOrYgp6gC6fE1nEZgrAOouoykhRo2NZo5U1-WLdDSc,7862
|
|
7
7
|
skrift/admin/__init__.py,sha256=x81Cj_ilVmv6slaMl16HHyT_AgrnLxKEWkS0RPa4V9s,289
|
|
8
8
|
skrift/admin/controller.py,sha256=gSprerjhxqxaH2lHm8vbLuz-jslN553FpbKF7jwEm9Y,19607
|
|
@@ -24,6 +24,10 @@ 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
|
|
@@ -80,7 +84,7 @@ skrift/templates/setup/configuring.html,sha256=2KHW9h2BrJgL_kO5IizbAYs4pnFLyRf76
|
|
|
80
84
|
skrift/templates/setup/database.html,sha256=gU4-315-QraHa2Eq4Fh3b55QpOM2CkJzh27_Yz13frA,5495
|
|
81
85
|
skrift/templates/setup/restart.html,sha256=GHg31F_e2uLFhWUzJoalk0Y0oYLqsFWyZXWKX3mblbY,1355
|
|
82
86
|
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.
|
|
87
|
+
skrift-0.1.0a16.dist-info/METADATA,sha256=aS6GVqS-9stFAZ_wRa86gOZHVfr21BRUTi91wae8Vqo,7267
|
|
88
|
+
skrift-0.1.0a16.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
89
|
+
skrift-0.1.0a16.dist-info/entry_points.txt,sha256=uquZ5Mumqr0xwYTpTcNiJtFSITGfF6_QCCy2DZJSZig,42
|
|
90
|
+
skrift-0.1.0a16.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|