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 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 = SQLAlchemyAsyncConfig(
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 = SQLAlchemyAsyncConfig(
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
@@ -110,6 +110,7 @@ class DatabaseConfig(BaseModel):
110
110
  pool_size: int = 5
111
111
  pool_overflow: int = 10
112
112
  pool_timeout: int = 30
113
+ pool_pre_ping: bool = True # Validate connections before use
113
114
  echo: bool = False
114
115
 
115
116
 
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"]
@@ -21,6 +21,7 @@ DEFAULT_CONFIG = {
21
21
  "pool_size": 5,
22
22
  "pool_overflow": 10,
23
23
  "pool_timeout": 30,
24
+ "pool_pre_ping": True,
24
25
  "echo": False,
25
26
  },
26
27
  "auth": {
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-db first
175
+ # Try skrift db first (the correct command)
176
176
  result = subprocess.run(
177
- ["skrift-db", "upgrade", "head"],
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-db fails, try alembic directly
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, "Neither skrift-db nor alembic found"
205
+ return False, "skrift db command not found"
206
206
  except Exception as e:
207
207
  return False, str(e)
208
208
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: skrift
3
- Version: 0.1.0a15
3
+ Version: 0.1.0a17
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
@@ -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=7uPvgsGMpZJyPOjIGxL9QlXTRLWh9uRAhcg-RdBS1c8,23355
5
- skrift/cli.py,sha256=aT-7pXvOuuZC-Eypx1h-xCiqaBKhIFjSqd5Ky_dHna0,4214
6
- skrift/config.py,sha256=ZDOrYgp6gC6fE1nEZgrAOouoykhRo2NZo5U1-WLdDSc,7862
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=YFH3FVjXN7Rum2fzGVPAQRkjdc9b0bHECDqMKYiEkhg,6347
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=RMe9LtIjzDoOm9u-Nk5-KAnr_JBiQIjWDpTP9E30ezc,9304
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.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,,
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,,