skrift 0.1.0a1__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.
Files changed (68) hide show
  1. skrift/__init__.py +1 -0
  2. skrift/__main__.py +17 -0
  3. skrift/admin/__init__.py +11 -0
  4. skrift/admin/controller.py +452 -0
  5. skrift/admin/navigation.py +105 -0
  6. skrift/alembic/env.py +91 -0
  7. skrift/alembic/script.py.mako +26 -0
  8. skrift/alembic/versions/20260120_210154_09b0364dbb7b_initial_schema.py +70 -0
  9. skrift/alembic/versions/20260122_152744_0b7c927d2591_add_roles_and_permissions.py +57 -0
  10. skrift/alembic/versions/20260122_172836_cdf734a5b847_add_sa_orm_sentinel_column.py +31 -0
  11. skrift/alembic/versions/20260122_175637_a9c55348eae7_remove_page_type_column.py +43 -0
  12. skrift/alembic/versions/20260122_200000_add_settings_table.py +38 -0
  13. skrift/alembic.ini +77 -0
  14. skrift/asgi.py +545 -0
  15. skrift/auth/__init__.py +58 -0
  16. skrift/auth/guards.py +130 -0
  17. skrift/auth/roles.py +94 -0
  18. skrift/auth/services.py +184 -0
  19. skrift/cli.py +45 -0
  20. skrift/config.py +192 -0
  21. skrift/controllers/__init__.py +4 -0
  22. skrift/controllers/auth.py +371 -0
  23. skrift/controllers/web.py +67 -0
  24. skrift/db/__init__.py +3 -0
  25. skrift/db/base.py +7 -0
  26. skrift/db/models/__init__.py +6 -0
  27. skrift/db/models/page.py +26 -0
  28. skrift/db/models/role.py +56 -0
  29. skrift/db/models/setting.py +13 -0
  30. skrift/db/models/user.py +36 -0
  31. skrift/db/services/__init__.py +1 -0
  32. skrift/db/services/page_service.py +217 -0
  33. skrift/db/services/setting_service.py +206 -0
  34. skrift/lib/__init__.py +3 -0
  35. skrift/lib/exceptions.py +168 -0
  36. skrift/lib/template.py +108 -0
  37. skrift/setup/__init__.py +14 -0
  38. skrift/setup/config_writer.py +211 -0
  39. skrift/setup/controller.py +751 -0
  40. skrift/setup/middleware.py +89 -0
  41. skrift/setup/providers.py +163 -0
  42. skrift/setup/state.py +134 -0
  43. skrift/static/css/style.css +998 -0
  44. skrift/templates/admin/admin.html +19 -0
  45. skrift/templates/admin/base.html +24 -0
  46. skrift/templates/admin/pages/edit.html +32 -0
  47. skrift/templates/admin/pages/list.html +62 -0
  48. skrift/templates/admin/settings/site.html +32 -0
  49. skrift/templates/admin/users/list.html +58 -0
  50. skrift/templates/admin/users/roles.html +42 -0
  51. skrift/templates/auth/login.html +125 -0
  52. skrift/templates/base.html +52 -0
  53. skrift/templates/error-404.html +19 -0
  54. skrift/templates/error-500.html +19 -0
  55. skrift/templates/error.html +19 -0
  56. skrift/templates/index.html +9 -0
  57. skrift/templates/page.html +26 -0
  58. skrift/templates/setup/admin.html +24 -0
  59. skrift/templates/setup/auth.html +110 -0
  60. skrift/templates/setup/base.html +407 -0
  61. skrift/templates/setup/complete.html +17 -0
  62. skrift/templates/setup/database.html +125 -0
  63. skrift/templates/setup/restart.html +28 -0
  64. skrift/templates/setup/site.html +39 -0
  65. skrift-0.1.0a1.dist-info/METADATA +233 -0
  66. skrift-0.1.0a1.dist-info/RECORD +68 -0
  67. skrift-0.1.0a1.dist-info/WHEEL +4 -0
  68. skrift-0.1.0a1.dist-info/entry_points.txt +3 -0
skrift/auth/guards.py ADDED
@@ -0,0 +1,130 @@
1
+ """Permission and Role guards for Litestar routes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from abc import ABC, abstractmethod
6
+ from typing import TYPE_CHECKING
7
+
8
+ from litestar.connection import ASGIConnection
9
+ from litestar.exceptions import NotAuthorizedException
10
+ from litestar.handlers import BaseRouteHandler
11
+
12
+ if TYPE_CHECKING:
13
+ from skrift.auth.services import UserPermissions
14
+
15
+
16
+ ADMINISTRATOR_PERMISSION = "administrator"
17
+
18
+
19
+ class AuthRequirement(ABC):
20
+ """Base class for authorization requirements with operator overloading."""
21
+
22
+ @abstractmethod
23
+ async def check(self, permissions: "UserPermissions") -> bool:
24
+ """Check if the requirement is satisfied."""
25
+ ...
26
+
27
+ def __or__(self, other: "AuthRequirement") -> "OrRequirement":
28
+ """Combine requirements with OR logic."""
29
+ return OrRequirement(self, other)
30
+
31
+ def __and__(self, other: "AuthRequirement") -> "AndRequirement":
32
+ """Combine requirements with AND logic."""
33
+ return AndRequirement(self, other)
34
+
35
+ def __call__(
36
+ self, connection: ASGIConnection, _: BaseRouteHandler
37
+ ) -> None:
38
+ """Guard function for use with Litestar guards parameter.
39
+
40
+ This is a synchronous wrapper - actual checking happens in the async guard.
41
+ """
42
+ pass
43
+
44
+
45
+ class OrRequirement(AuthRequirement):
46
+ """Combines two requirements with OR logic."""
47
+
48
+ def __init__(self, left: AuthRequirement, right: AuthRequirement):
49
+ self.left = left
50
+ self.right = right
51
+
52
+ async def check(self, permissions: "UserPermissions") -> bool:
53
+ """Return True if either requirement is satisfied."""
54
+ return await self.left.check(permissions) or await self.right.check(permissions)
55
+
56
+
57
+ class AndRequirement(AuthRequirement):
58
+ """Combines two requirements with AND logic."""
59
+
60
+ def __init__(self, left: AuthRequirement, right: AuthRequirement):
61
+ self.left = left
62
+ self.right = right
63
+
64
+ async def check(self, permissions: "UserPermissions") -> bool:
65
+ """Return True if both requirements are satisfied."""
66
+ return await self.left.check(permissions) and await self.right.check(permissions)
67
+
68
+
69
+ class Permission(AuthRequirement):
70
+ """Permission requirement for route guards."""
71
+
72
+ def __init__(self, permission: str):
73
+ self.permission = permission
74
+
75
+ async def check(self, permissions: "UserPermissions") -> bool:
76
+ """Check if user has the required permission or administrator permission."""
77
+ # Administrator permission bypasses all checks
78
+ if ADMINISTRATOR_PERMISSION in permissions.permissions:
79
+ return True
80
+ return self.permission in permissions.permissions
81
+
82
+
83
+ class Role(AuthRequirement):
84
+ """Role requirement for route guards."""
85
+
86
+ def __init__(self, role: str):
87
+ self.role = role
88
+
89
+ async def check(self, permissions: "UserPermissions") -> bool:
90
+ """Check if user has the required role or administrator permission."""
91
+ # Administrator permission bypasses all checks
92
+ if ADMINISTRATOR_PERMISSION in permissions.permissions:
93
+ return True
94
+ return self.role in permissions.roles
95
+
96
+
97
+ async def auth_guard(
98
+ connection: ASGIConnection, route_handler: BaseRouteHandler
99
+ ) -> None:
100
+ """Litestar guard that checks authentication and authorization requirements.
101
+
102
+ This guard checks the guards parameter of route handlers for AuthRequirement
103
+ instances and validates them against the user's permissions and roles.
104
+ """
105
+ from skrift.auth.services import get_user_permissions
106
+
107
+ # Get user_id from session
108
+ user_id = connection.session.get("user_id") if connection.session else None
109
+
110
+ if not user_id:
111
+ raise NotAuthorizedException("Authentication required")
112
+
113
+ # Get the guards from the route handler
114
+ guards = route_handler.guards or []
115
+
116
+ # Find AuthRequirement guards
117
+ auth_requirements = [g for g in guards if isinstance(g, AuthRequirement)]
118
+
119
+ if not auth_requirements:
120
+ return # No auth requirements, just needs to be logged in
121
+
122
+ # Get user's permissions and roles
123
+ session_maker = connection.app.state.session_maker_class
124
+ async with session_maker() as session:
125
+ permissions = await get_user_permissions(session, user_id)
126
+
127
+ # Check all requirements
128
+ for requirement in auth_requirements:
129
+ if not await requirement.check(permissions):
130
+ raise NotAuthorizedException("Insufficient permissions")
skrift/auth/roles.py ADDED
@@ -0,0 +1,94 @@
1
+ """Role definitions for the application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass
9
+ class RoleDefinition:
10
+ """Definition of a role with its permissions."""
11
+
12
+ name: str
13
+ permissions: set[str] = field(default_factory=set)
14
+ display_name: str | None = None
15
+ description: str | None = None
16
+
17
+
18
+ def create_role(
19
+ name: str,
20
+ *permissions: str,
21
+ display_name: str | None = None,
22
+ description: str | None = None,
23
+ ) -> RoleDefinition:
24
+ """Create a role definition with the given permissions.
25
+
26
+ Args:
27
+ name: The unique identifier for the role
28
+ *permissions: Permission strings granted by this role
29
+ display_name: Human-readable name for the role
30
+ description: Description of the role's purpose
31
+
32
+ Returns:
33
+ A RoleDefinition instance
34
+ """
35
+ return RoleDefinition(
36
+ name=name,
37
+ permissions=set(permissions),
38
+ display_name=display_name or name.title(),
39
+ description=description,
40
+ )
41
+
42
+
43
+ # Default role definitions
44
+ # The "administrator" permission is special - it bypasses all permission checks
45
+
46
+ ADMIN = create_role(
47
+ "admin",
48
+ "administrator",
49
+ "manage-users",
50
+ "manage-pages",
51
+ "modify-site",
52
+ display_name="Administrator",
53
+ description="Full system access with all permissions",
54
+ )
55
+
56
+ AUTHOR = create_role(
57
+ "author",
58
+ "view-drafts",
59
+ display_name="Author",
60
+ description="Can view draft content",
61
+ )
62
+
63
+ EDITOR = create_role(
64
+ "editor",
65
+ "view-drafts",
66
+ "manage-pages",
67
+ display_name="Editor",
68
+ description="Can manage pages and view drafts",
69
+ )
70
+
71
+ MODERATOR = create_role(
72
+ "moderator",
73
+ "view-drafts",
74
+ display_name="Moderator",
75
+ description="Can moderate content",
76
+ )
77
+
78
+ # Registry of all role definitions
79
+ ROLE_DEFINITIONS: dict[str, RoleDefinition] = {
80
+ role.name: role for role in [ADMIN, AUTHOR, EDITOR, MODERATOR]
81
+ }
82
+
83
+
84
+ def get_role_definition(name: str) -> RoleDefinition | None:
85
+ """Get a role definition by name."""
86
+ return ROLE_DEFINITIONS.get(name)
87
+
88
+
89
+ def register_role(role: RoleDefinition) -> None:
90
+ """Register a custom role definition.
91
+
92
+ This allows applications to add custom roles beyond the defaults.
93
+ """
94
+ ROLE_DEFINITIONS[role.name] = role
@@ -0,0 +1,184 @@
1
+ """Authentication and authorization services."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from datetime import datetime, timedelta
7
+ from typing import TYPE_CHECKING
8
+ from uuid import UUID
9
+
10
+ from sqlalchemy import delete, select
11
+ from sqlalchemy.orm import selectinload
12
+
13
+ from skrift.db.models.role import Role, RolePermission
14
+
15
+ if TYPE_CHECKING:
16
+ from sqlalchemy.ext.asyncio import AsyncSession
17
+
18
+
19
+ # Cache for user permissions with TTL
20
+ _permission_cache: dict[str, tuple[datetime, "UserPermissions"]] = {}
21
+ CACHE_TTL = timedelta(minutes=5)
22
+
23
+
24
+ @dataclass
25
+ class UserPermissions:
26
+ """Container for a user's permissions and roles."""
27
+
28
+ user_id: str
29
+ roles: set[str] = field(default_factory=set)
30
+ permissions: set[str] = field(default_factory=set)
31
+
32
+
33
+ async def get_user_permissions(
34
+ session: "AsyncSession", user_id: str | UUID
35
+ ) -> UserPermissions:
36
+ """Get all permissions and roles for a user.
37
+
38
+ Results are cached with a TTL for performance.
39
+ """
40
+ user_id_str = str(user_id)
41
+
42
+ # Check cache
43
+ if user_id_str in _permission_cache:
44
+ cached_time, cached_perms = _permission_cache[user_id_str]
45
+ if datetime.now() - cached_time < CACHE_TTL:
46
+ return cached_perms
47
+
48
+ # Query user with roles and permissions
49
+ from skrift.db.models.user import User
50
+
51
+ result = await session.execute(
52
+ select(User)
53
+ .where(User.id == (UUID(user_id_str) if isinstance(user_id, str) else user_id))
54
+ .options(selectinload(User.roles).selectinload(Role.permissions))
55
+ )
56
+ user = result.scalar_one_or_none()
57
+
58
+ permissions = UserPermissions(user_id=user_id_str)
59
+
60
+ if user:
61
+ for role in user.roles:
62
+ permissions.roles.add(role.name)
63
+ for role_perm in role.permissions:
64
+ permissions.permissions.add(role_perm.permission)
65
+
66
+ # Update cache
67
+ _permission_cache[user_id_str] = (datetime.now(), permissions)
68
+
69
+ return permissions
70
+
71
+
72
+ def invalidate_user_permissions_cache(user_id: str | UUID | None = None) -> None:
73
+ """Invalidate cached permissions for a user or all users.
74
+
75
+ Args:
76
+ user_id: Specific user to invalidate, or None to clear all cache
77
+ """
78
+ if user_id is None:
79
+ _permission_cache.clear()
80
+ else:
81
+ _permission_cache.pop(str(user_id), None)
82
+
83
+
84
+ async def assign_role_to_user(
85
+ session: "AsyncSession", user_id: str | UUID, role_name: str
86
+ ) -> bool:
87
+ """Assign a role to a user.
88
+
89
+ Returns True if the role was assigned, False if role not found.
90
+ """
91
+ from skrift.db.models.user import User
92
+
93
+ user_id_uuid = UUID(user_id) if isinstance(user_id, str) else user_id
94
+
95
+ # Get user and role
96
+ user_result = await session.execute(
97
+ select(User).where(User.id == user_id_uuid).options(selectinload(User.roles))
98
+ )
99
+ user = user_result.scalar_one_or_none()
100
+
101
+ role_result = await session.execute(select(Role).where(Role.name == role_name))
102
+ role = role_result.scalar_one_or_none()
103
+
104
+ if not user or not role:
105
+ return False
106
+
107
+ # Check if already assigned
108
+ if role not in user.roles:
109
+ user.roles.append(role)
110
+ await session.commit()
111
+ invalidate_user_permissions_cache(user_id)
112
+
113
+ return True
114
+
115
+
116
+ async def remove_role_from_user(
117
+ session: "AsyncSession", user_id: str | UUID, role_name: str
118
+ ) -> bool:
119
+ """Remove a role from a user.
120
+
121
+ Returns True if the role was removed, False if not found.
122
+ """
123
+ from skrift.db.models.user import User
124
+
125
+ user_id_uuid = UUID(user_id) if isinstance(user_id, str) else user_id
126
+
127
+ # Get user with roles
128
+ user_result = await session.execute(
129
+ select(User).where(User.id == user_id_uuid).options(selectinload(User.roles))
130
+ )
131
+ user = user_result.scalar_one_or_none()
132
+
133
+ if not user:
134
+ return False
135
+
136
+ # Find and remove the role
137
+ for role in user.roles:
138
+ if role.name == role_name:
139
+ user.roles.remove(role)
140
+ await session.commit()
141
+ invalidate_user_permissions_cache(user_id)
142
+ return True
143
+
144
+ return False
145
+
146
+
147
+ async def sync_roles_to_database(session: "AsyncSession") -> None:
148
+ """Sync role definitions from code to the database.
149
+
150
+ This creates or updates roles based on the definitions in roles.py.
151
+ """
152
+ from skrift.auth.roles import ROLE_DEFINITIONS
153
+
154
+ for role_def in ROLE_DEFINITIONS.values():
155
+ # Check if role exists
156
+ result = await session.execute(select(Role).where(Role.name == role_def.name))
157
+ role = result.scalar_one_or_none()
158
+
159
+ if role:
160
+ # Update existing role
161
+ role.display_name = role_def.display_name
162
+ role.description = role_def.description
163
+
164
+ # Remove old permissions
165
+ await session.execute(
166
+ delete(RolePermission).where(RolePermission.role_id == role.id)
167
+ )
168
+ else:
169
+ # Create new role
170
+ role = Role(
171
+ name=role_def.name,
172
+ display_name=role_def.display_name,
173
+ description=role_def.description,
174
+ )
175
+ session.add(role)
176
+ await session.flush() # Get the role ID
177
+
178
+ # Add permissions
179
+ for permission in role_def.permissions:
180
+ role_permission = RolePermission(role_id=role.id, permission=permission)
181
+ session.add(role_permission)
182
+
183
+ await session.commit()
184
+ invalidate_user_permissions_cache()
skrift/cli.py ADDED
@@ -0,0 +1,45 @@
1
+ """CLI commands for Skrift database management."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+
6
+
7
+ def db() -> None:
8
+ """Run Alembic database migrations.
9
+
10
+ This is a thin wrapper around Alembic that sets up the correct working
11
+ directory and passes through all arguments.
12
+
13
+ Usage:
14
+ skrift-db upgrade head # Apply all migrations
15
+ skrift-db downgrade -1 # Rollback one migration
16
+ skrift-db current # Show current revision
17
+ skrift-db history # Show migration history
18
+ skrift-db revision -m "description" --autogenerate # Create new migration
19
+ """
20
+ from alembic.config import main as alembic_main
21
+
22
+ # Ensure we're running from the project root where alembic.ini is located
23
+ # If alembic.ini is not in cwd, check common locations
24
+ alembic_ini = Path.cwd() / "alembic.ini"
25
+
26
+ if not alembic_ini.exists():
27
+ # Try to find alembic.ini relative to this module
28
+ module_dir = Path(__file__).parent.parent
29
+ alembic_ini = module_dir / "alembic.ini"
30
+
31
+ if alembic_ini.exists():
32
+ # Change to the directory containing alembic.ini
33
+ import os
34
+ os.chdir(module_dir)
35
+ else:
36
+ print("Error: Could not find alembic.ini", file=sys.stderr)
37
+ print("Make sure you're running from the project root directory.", file=sys.stderr)
38
+ sys.exit(1)
39
+
40
+ # Pass through all CLI arguments to Alembic
41
+ sys.exit(alembic_main(sys.argv[1:]))
42
+
43
+
44
+ if __name__ == "__main__":
45
+ db()
skrift/config.py ADDED
@@ -0,0 +1,192 @@
1
+ import os
2
+ import re
3
+ from functools import lru_cache
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+ from dotenv import load_dotenv
8
+ from pydantic import BaseModel
9
+ from pydantic_settings import BaseSettings, SettingsConfigDict
10
+
11
+ # Load .env file early so env vars are available for YAML interpolation
12
+ # Use explicit path to handle subprocess spawning (uvicorn workers)
13
+ _env_file = Path(__file__).parent.parent / ".env"
14
+ load_dotenv(_env_file)
15
+
16
+ # Pattern to match $VAR_NAME environment variable references
17
+ ENV_VAR_PATTERN = re.compile(r"\$([A-Z_][A-Z0-9_]*)")
18
+
19
+
20
+ def interpolate_env_vars(value, strict: bool = True):
21
+ """Recursively replace $VAR_NAME with os.environ values.
22
+
23
+ Args:
24
+ value: The value to interpolate
25
+ strict: If True, raise an error when env var is not set.
26
+ If False, return the original $VAR_NAME reference.
27
+ """
28
+ if isinstance(value, str):
29
+
30
+ def replace(match):
31
+ var = match.group(1)
32
+ val = os.environ.get(var)
33
+ if val is None:
34
+ if strict:
35
+ raise ValueError(f"Environment variable ${var} not set")
36
+ return match.group(0) # Return original $VAR_NAME
37
+ return val
38
+
39
+ return ENV_VAR_PATTERN.sub(replace, value)
40
+ elif isinstance(value, dict):
41
+ return {k: interpolate_env_vars(v, strict) for k, v in value.items()}
42
+ elif isinstance(value, list):
43
+ return [interpolate_env_vars(item, strict) for item in value]
44
+ return value
45
+
46
+
47
+ def load_app_config(interpolate: bool = True, strict: bool = True) -> dict:
48
+ """Load and parse app.yaml with optional environment variable interpolation.
49
+
50
+ Args:
51
+ interpolate: Whether to interpolate environment variables
52
+ strict: If interpolating, whether to raise errors for missing env vars
53
+
54
+ Returns:
55
+ Parsed configuration dictionary
56
+ """
57
+ config_path = Path.cwd() / "app.yaml"
58
+
59
+ if not config_path.exists():
60
+ raise FileNotFoundError(f"app.yaml not found at {config_path}")
61
+
62
+ with open(config_path, "r") as f:
63
+ config = yaml.safe_load(f)
64
+
65
+ if interpolate:
66
+ return interpolate_env_vars(config, strict=strict)
67
+ return config
68
+
69
+
70
+ def load_raw_app_config() -> dict | None:
71
+ """Load app.yaml without any processing. Returns None if file doesn't exist."""
72
+ config_path = Path.cwd() / "app.yaml"
73
+
74
+ if not config_path.exists():
75
+ return None
76
+
77
+ with open(config_path, "r") as f:
78
+ return yaml.safe_load(f)
79
+
80
+
81
+ class DatabaseConfig(BaseModel):
82
+ """Database connection configuration."""
83
+
84
+ url: str = "sqlite+aiosqlite:///./app.db"
85
+ pool_size: int = 5
86
+ pool_overflow: int = 10
87
+ pool_timeout: int = 30
88
+ echo: bool = False
89
+
90
+
91
+ class OAuthProviderConfig(BaseModel):
92
+ """OAuth provider configuration."""
93
+
94
+ client_id: str
95
+ client_secret: str
96
+ scopes: list[str] = ["openid", "email", "profile"]
97
+ # Optional tenant ID for Microsoft/Azure AD
98
+ tenant_id: str | None = None
99
+
100
+
101
+ class AuthConfig(BaseModel):
102
+ """Authentication configuration."""
103
+
104
+ redirect_base_url: str = "http://localhost:8000"
105
+ providers: dict[str, OAuthProviderConfig] = {}
106
+
107
+ def get_redirect_uri(self, provider: str) -> str:
108
+ """Get the OAuth callback URL for a provider."""
109
+ return f"{self.redirect_base_url}/auth/{provider}/callback"
110
+
111
+
112
+ class Settings(BaseSettings):
113
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
114
+
115
+ # Application
116
+ debug: bool = False
117
+ secret_key: str
118
+
119
+ # Database config (loaded from app.yaml)
120
+ db: DatabaseConfig = DatabaseConfig()
121
+
122
+ # Auth config (loaded from app.yaml)
123
+ auth: AuthConfig = AuthConfig()
124
+
125
+
126
+ def clear_settings_cache() -> None:
127
+ """Clear the settings cache to force reload."""
128
+ get_settings.cache_clear()
129
+
130
+
131
+ def is_config_valid() -> tuple[bool, str | None]:
132
+ """Check if the current configuration is valid and complete.
133
+
134
+ Returns:
135
+ Tuple of (is_valid, error_message)
136
+ """
137
+ try:
138
+ config = load_raw_app_config()
139
+ if config is None:
140
+ return False, "app.yaml not found"
141
+
142
+ # Check database URL
143
+ db_config = config.get("db", {})
144
+ db_url = db_config.get("url")
145
+ if not db_url:
146
+ return False, "Database URL not configured"
147
+
148
+ # If it's an env var reference, check if env var is set
149
+ if isinstance(db_url, str) and db_url.startswith("$"):
150
+ env_var = db_url[1:]
151
+ if not os.environ.get(env_var):
152
+ return False, f"Database environment variable ${env_var} not set"
153
+
154
+ # Check auth providers
155
+ auth_config = config.get("auth", {})
156
+ providers = auth_config.get("providers", {})
157
+ if not providers:
158
+ return False, "No authentication providers configured"
159
+
160
+ return True, None
161
+ except Exception as e:
162
+ return False, str(e)
163
+
164
+
165
+ @lru_cache
166
+ def get_settings() -> Settings:
167
+ """Load settings from .env and app.yaml."""
168
+ # First create base settings from .env
169
+ base_settings = Settings()
170
+
171
+ # Load app.yaml config
172
+ try:
173
+ app_config = load_app_config()
174
+ except FileNotFoundError:
175
+ return base_settings
176
+ except ValueError:
177
+ # Missing environment variables - return base settings
178
+ return base_settings
179
+
180
+ # Merge YAML config with settings
181
+ updates = {}
182
+
183
+ if "db" in app_config:
184
+ updates["db"] = DatabaseConfig(**app_config["db"])
185
+
186
+ if "auth" in app_config:
187
+ updates["auth"] = AuthConfig(**app_config["auth"])
188
+
189
+ if updates:
190
+ return base_settings.model_copy(update=updates)
191
+
192
+ return base_settings
@@ -0,0 +1,4 @@
1
+ from skrift.controllers.auth import AuthController
2
+ from skrift.controllers.web import WebController
3
+
4
+ __all__ = ["AuthController", "WebController"]