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.
@@ -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()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skrift
3
- Version: 0.1.0a15
3
+ Version: 0.1.0a16
4
4
  Summary: A lightweight async Python CMS for crafting modern websites
5
5
  Requires-Python: >=3.13
6
6
  Requires-Dist: advanced-alchemy>=0.26.0
@@ -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=aT-7pXvOuuZC-Eypx1h-xCiqaBKhIFjSqd5Ky_dHna0,4214
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.0a15.dist-info/METADATA,sha256=6_eCcfYQ2K5uH_acqd3Q3FmEby0a1Yx710xIiEBa3rs,7267
84
- skrift-0.1.0a15.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
85
- skrift-0.1.0a15.dist-info/entry_points.txt,sha256=uquZ5Mumqr0xwYTpTcNiJtFSITGfF6_QCCy2DZJSZig,42
86
- skrift-0.1.0a15.dist-info/RECORD,,
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,,