skrift 0.1.0a12__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/__init__.py +1 -0
- skrift/__main__.py +12 -0
- skrift/admin/__init__.py +11 -0
- skrift/admin/controller.py +452 -0
- skrift/admin/navigation.py +105 -0
- skrift/alembic/env.py +92 -0
- skrift/alembic/script.py.mako +26 -0
- skrift/alembic/versions/20260120_210154_09b0364dbb7b_initial_schema.py +70 -0
- skrift/alembic/versions/20260122_152744_0b7c927d2591_add_roles_and_permissions.py +57 -0
- skrift/alembic/versions/20260122_172836_cdf734a5b847_add_sa_orm_sentinel_column.py +31 -0
- skrift/alembic/versions/20260122_175637_a9c55348eae7_remove_page_type_column.py +43 -0
- skrift/alembic/versions/20260122_200000_add_settings_table.py +38 -0
- skrift/alembic/versions/20260129_add_oauth_accounts.py +141 -0
- skrift/alembic/versions/20260129_add_provider_metadata.py +29 -0
- skrift/alembic.ini +77 -0
- skrift/asgi.py +670 -0
- skrift/auth/__init__.py +58 -0
- skrift/auth/guards.py +130 -0
- skrift/auth/roles.py +129 -0
- skrift/auth/services.py +184 -0
- skrift/cli.py +143 -0
- skrift/config.py +259 -0
- skrift/controllers/__init__.py +4 -0
- skrift/controllers/auth.py +595 -0
- skrift/controllers/web.py +67 -0
- skrift/db/__init__.py +3 -0
- skrift/db/base.py +7 -0
- skrift/db/models/__init__.py +7 -0
- skrift/db/models/oauth_account.py +50 -0
- skrift/db/models/page.py +26 -0
- skrift/db/models/role.py +56 -0
- skrift/db/models/setting.py +13 -0
- skrift/db/models/user.py +36 -0
- skrift/db/services/__init__.py +1 -0
- skrift/db/services/oauth_service.py +195 -0
- skrift/db/services/page_service.py +217 -0
- skrift/db/services/setting_service.py +206 -0
- skrift/lib/__init__.py +3 -0
- skrift/lib/exceptions.py +168 -0
- skrift/lib/template.py +108 -0
- skrift/setup/__init__.py +14 -0
- skrift/setup/config_writer.py +213 -0
- skrift/setup/controller.py +888 -0
- skrift/setup/middleware.py +89 -0
- skrift/setup/providers.py +214 -0
- skrift/setup/state.py +315 -0
- skrift/static/css/style.css +1003 -0
- skrift/templates/admin/admin.html +19 -0
- skrift/templates/admin/base.html +24 -0
- skrift/templates/admin/pages/edit.html +32 -0
- skrift/templates/admin/pages/list.html +62 -0
- skrift/templates/admin/settings/site.html +32 -0
- skrift/templates/admin/users/list.html +58 -0
- skrift/templates/admin/users/roles.html +42 -0
- skrift/templates/auth/dummy_login.html +102 -0
- skrift/templates/auth/login.html +139 -0
- skrift/templates/base.html +52 -0
- skrift/templates/error-404.html +19 -0
- skrift/templates/error-500.html +19 -0
- skrift/templates/error.html +19 -0
- skrift/templates/index.html +9 -0
- skrift/templates/page.html +26 -0
- skrift/templates/setup/admin.html +24 -0
- skrift/templates/setup/auth.html +110 -0
- skrift/templates/setup/base.html +407 -0
- skrift/templates/setup/complete.html +17 -0
- skrift/templates/setup/configuring.html +158 -0
- skrift/templates/setup/database.html +125 -0
- skrift/templates/setup/restart.html +28 -0
- skrift/templates/setup/site.html +39 -0
- skrift-0.1.0a12.dist-info/METADATA +235 -0
- skrift-0.1.0a12.dist-info/RECORD +74 -0
- skrift-0.1.0a12.dist-info/WHEEL +4 -0
- skrift-0.1.0a12.dist-info/entry_points.txt +2 -0
skrift/config.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
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
|
+
# Load from current working directory (where app.yaml lives)
|
|
13
|
+
_env_file = Path.cwd() / ".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
|
+
# Environment configuration
|
|
20
|
+
SKRIFT_ENV = "SKRIFT_ENV"
|
|
21
|
+
DEFAULT_ENVIRONMENT = "production"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_environment() -> str:
|
|
25
|
+
"""Get the current environment name, normalized to lowercase.
|
|
26
|
+
|
|
27
|
+
Reads from SKRIFT_ENV environment variable. Defaults to "production".
|
|
28
|
+
"""
|
|
29
|
+
env = os.environ.get(SKRIFT_ENV, DEFAULT_ENVIRONMENT)
|
|
30
|
+
return env.lower().strip()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def get_config_path() -> Path:
|
|
34
|
+
"""Get the path to the environment-specific config file.
|
|
35
|
+
|
|
36
|
+
Production -> app.yaml
|
|
37
|
+
Other envs -> app.{env}.yaml (e.g., app.dev.yaml)
|
|
38
|
+
"""
|
|
39
|
+
env = get_environment()
|
|
40
|
+
if env == "production":
|
|
41
|
+
return Path.cwd() / "app.yaml"
|
|
42
|
+
return Path.cwd() / f"app.{env}.yaml"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def interpolate_env_vars(value, strict: bool = True):
|
|
46
|
+
"""Recursively replace $VAR_NAME with os.environ values.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
value: The value to interpolate
|
|
50
|
+
strict: If True, raise an error when env var is not set.
|
|
51
|
+
If False, return the original $VAR_NAME reference.
|
|
52
|
+
"""
|
|
53
|
+
if isinstance(value, str):
|
|
54
|
+
|
|
55
|
+
def replace(match):
|
|
56
|
+
var = match.group(1)
|
|
57
|
+
val = os.environ.get(var)
|
|
58
|
+
if val is None:
|
|
59
|
+
if strict:
|
|
60
|
+
raise ValueError(f"Environment variable ${var} not set")
|
|
61
|
+
return match.group(0) # Return original $VAR_NAME
|
|
62
|
+
return val
|
|
63
|
+
|
|
64
|
+
return ENV_VAR_PATTERN.sub(replace, value)
|
|
65
|
+
elif isinstance(value, dict):
|
|
66
|
+
return {k: interpolate_env_vars(v, strict) for k, v in value.items()}
|
|
67
|
+
elif isinstance(value, list):
|
|
68
|
+
return [interpolate_env_vars(item, strict) for item in value]
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def load_app_config(interpolate: bool = True, strict: bool = True) -> dict:
|
|
73
|
+
"""Load and parse app.yaml with optional environment variable interpolation.
|
|
74
|
+
|
|
75
|
+
Args:
|
|
76
|
+
interpolate: Whether to interpolate environment variables
|
|
77
|
+
strict: If interpolating, whether to raise errors for missing env vars
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
Parsed configuration dictionary
|
|
81
|
+
"""
|
|
82
|
+
config_path = get_config_path()
|
|
83
|
+
|
|
84
|
+
if not config_path.exists():
|
|
85
|
+
raise FileNotFoundError(f"{config_path.name} not found at {config_path}")
|
|
86
|
+
|
|
87
|
+
with open(config_path, "r") as f:
|
|
88
|
+
config = yaml.safe_load(f)
|
|
89
|
+
|
|
90
|
+
if interpolate:
|
|
91
|
+
return interpolate_env_vars(config, strict=strict)
|
|
92
|
+
return config
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def load_raw_app_config() -> dict | None:
|
|
96
|
+
"""Load app.yaml without any processing. Returns None if file doesn't exist."""
|
|
97
|
+
config_path = get_config_path()
|
|
98
|
+
|
|
99
|
+
if not config_path.exists():
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
with open(config_path, "r") as f:
|
|
103
|
+
return yaml.safe_load(f)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
class DatabaseConfig(BaseModel):
|
|
107
|
+
"""Database connection configuration."""
|
|
108
|
+
|
|
109
|
+
url: str = "sqlite+aiosqlite:///./app.db"
|
|
110
|
+
pool_size: int = 5
|
|
111
|
+
pool_overflow: int = 10
|
|
112
|
+
pool_timeout: int = 30
|
|
113
|
+
echo: bool = False
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class OAuthProviderConfig(BaseModel):
|
|
117
|
+
"""OAuth provider configuration."""
|
|
118
|
+
|
|
119
|
+
client_id: str
|
|
120
|
+
client_secret: str
|
|
121
|
+
scopes: list[str] = ["openid", "email", "profile"]
|
|
122
|
+
# Optional tenant ID for Microsoft/Azure AD
|
|
123
|
+
tenant_id: str | None = None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class DummyProviderConfig(BaseModel):
|
|
127
|
+
"""Dummy provider configuration (no credentials required)."""
|
|
128
|
+
|
|
129
|
+
pass
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
# Union type for provider configs - dummy has no required fields
|
|
133
|
+
ProviderConfig = OAuthProviderConfig | DummyProviderConfig
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class SessionConfig(BaseModel):
|
|
137
|
+
"""Session cookie configuration."""
|
|
138
|
+
|
|
139
|
+
cookie_domain: str | None = None # None = exact host only
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class AuthConfig(BaseModel):
|
|
143
|
+
"""Authentication configuration."""
|
|
144
|
+
|
|
145
|
+
redirect_base_url: str = "http://localhost:8000"
|
|
146
|
+
allowed_redirect_domains: list[str] = []
|
|
147
|
+
providers: dict[str, ProviderConfig] = {}
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def _parse_provider(cls, name: str, config: dict) -> ProviderConfig:
|
|
151
|
+
"""Parse a provider config, using the appropriate model based on provider name."""
|
|
152
|
+
if name == "dummy":
|
|
153
|
+
return DummyProviderConfig(**config)
|
|
154
|
+
return OAuthProviderConfig(**config)
|
|
155
|
+
|
|
156
|
+
def __init__(self, **data):
|
|
157
|
+
# Convert raw provider dicts to appropriate config objects
|
|
158
|
+
if "providers" in data and isinstance(data["providers"], dict):
|
|
159
|
+
parsed_providers = {}
|
|
160
|
+
for name, config in data["providers"].items():
|
|
161
|
+
if isinstance(config, dict):
|
|
162
|
+
parsed_providers[name] = self._parse_provider(name, config)
|
|
163
|
+
else:
|
|
164
|
+
parsed_providers[name] = config
|
|
165
|
+
data["providers"] = parsed_providers
|
|
166
|
+
super().__init__(**data)
|
|
167
|
+
|
|
168
|
+
def get_redirect_uri(self, provider: str) -> str:
|
|
169
|
+
"""Get the OAuth callback URL for a provider."""
|
|
170
|
+
return f"{self.redirect_base_url}/auth/{provider}/callback"
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class Settings(BaseSettings):
|
|
174
|
+
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
|
175
|
+
|
|
176
|
+
# Application
|
|
177
|
+
debug: bool = False
|
|
178
|
+
secret_key: str
|
|
179
|
+
|
|
180
|
+
# Database config (loaded from app.yaml)
|
|
181
|
+
db: DatabaseConfig = DatabaseConfig()
|
|
182
|
+
|
|
183
|
+
# Auth config (loaded from app.yaml)
|
|
184
|
+
auth: AuthConfig = AuthConfig()
|
|
185
|
+
|
|
186
|
+
# Session config (loaded from app.yaml)
|
|
187
|
+
session: SessionConfig = SessionConfig()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def clear_settings_cache() -> None:
|
|
191
|
+
"""Clear the settings cache to force reload."""
|
|
192
|
+
get_settings.cache_clear()
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def is_config_valid() -> tuple[bool, str | None]:
|
|
196
|
+
"""Check if the current configuration is valid and complete.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Tuple of (is_valid, error_message)
|
|
200
|
+
"""
|
|
201
|
+
try:
|
|
202
|
+
config = load_raw_app_config()
|
|
203
|
+
if config is None:
|
|
204
|
+
return False, f"{get_config_path().name} not found"
|
|
205
|
+
|
|
206
|
+
# Check database URL
|
|
207
|
+
db_config = config.get("db", {})
|
|
208
|
+
db_url = db_config.get("url")
|
|
209
|
+
if not db_url:
|
|
210
|
+
return False, "Database URL not configured"
|
|
211
|
+
|
|
212
|
+
# If it's an env var reference, check if env var is set
|
|
213
|
+
if isinstance(db_url, str) and db_url.startswith("$"):
|
|
214
|
+
env_var = db_url[1:]
|
|
215
|
+
if not os.environ.get(env_var):
|
|
216
|
+
return False, f"Database environment variable ${env_var} not set"
|
|
217
|
+
|
|
218
|
+
# Check auth providers
|
|
219
|
+
auth_config = config.get("auth", {})
|
|
220
|
+
providers = auth_config.get("providers", {})
|
|
221
|
+
if not providers:
|
|
222
|
+
return False, "No authentication providers configured"
|
|
223
|
+
|
|
224
|
+
return True, None
|
|
225
|
+
except Exception as e:
|
|
226
|
+
return False, str(e)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@lru_cache
|
|
230
|
+
def get_settings() -> Settings:
|
|
231
|
+
"""Load settings from .env and app.yaml."""
|
|
232
|
+
# First create base settings from .env
|
|
233
|
+
base_settings = Settings()
|
|
234
|
+
|
|
235
|
+
# Load app.yaml config
|
|
236
|
+
try:
|
|
237
|
+
app_config = load_app_config()
|
|
238
|
+
except FileNotFoundError:
|
|
239
|
+
return base_settings
|
|
240
|
+
except ValueError:
|
|
241
|
+
# Missing environment variables - return base settings
|
|
242
|
+
return base_settings
|
|
243
|
+
|
|
244
|
+
# Merge YAML config with settings
|
|
245
|
+
updates = {}
|
|
246
|
+
|
|
247
|
+
if "db" in app_config:
|
|
248
|
+
updates["db"] = DatabaseConfig(**app_config["db"])
|
|
249
|
+
|
|
250
|
+
if "auth" in app_config:
|
|
251
|
+
updates["auth"] = AuthConfig(**app_config["auth"])
|
|
252
|
+
|
|
253
|
+
if "session" in app_config:
|
|
254
|
+
updates["session"] = SessionConfig(**app_config["session"])
|
|
255
|
+
|
|
256
|
+
if updates:
|
|
257
|
+
return base_settings.model_copy(update=updates)
|
|
258
|
+
|
|
259
|
+
return base_settings
|