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.
Files changed (74) hide show
  1. skrift/__init__.py +1 -0
  2. skrift/__main__.py +12 -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 +92 -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/versions/20260129_add_oauth_accounts.py +141 -0
  14. skrift/alembic/versions/20260129_add_provider_metadata.py +29 -0
  15. skrift/alembic.ini +77 -0
  16. skrift/asgi.py +670 -0
  17. skrift/auth/__init__.py +58 -0
  18. skrift/auth/guards.py +130 -0
  19. skrift/auth/roles.py +129 -0
  20. skrift/auth/services.py +184 -0
  21. skrift/cli.py +143 -0
  22. skrift/config.py +259 -0
  23. skrift/controllers/__init__.py +4 -0
  24. skrift/controllers/auth.py +595 -0
  25. skrift/controllers/web.py +67 -0
  26. skrift/db/__init__.py +3 -0
  27. skrift/db/base.py +7 -0
  28. skrift/db/models/__init__.py +7 -0
  29. skrift/db/models/oauth_account.py +50 -0
  30. skrift/db/models/page.py +26 -0
  31. skrift/db/models/role.py +56 -0
  32. skrift/db/models/setting.py +13 -0
  33. skrift/db/models/user.py +36 -0
  34. skrift/db/services/__init__.py +1 -0
  35. skrift/db/services/oauth_service.py +195 -0
  36. skrift/db/services/page_service.py +217 -0
  37. skrift/db/services/setting_service.py +206 -0
  38. skrift/lib/__init__.py +3 -0
  39. skrift/lib/exceptions.py +168 -0
  40. skrift/lib/template.py +108 -0
  41. skrift/setup/__init__.py +14 -0
  42. skrift/setup/config_writer.py +213 -0
  43. skrift/setup/controller.py +888 -0
  44. skrift/setup/middleware.py +89 -0
  45. skrift/setup/providers.py +214 -0
  46. skrift/setup/state.py +315 -0
  47. skrift/static/css/style.css +1003 -0
  48. skrift/templates/admin/admin.html +19 -0
  49. skrift/templates/admin/base.html +24 -0
  50. skrift/templates/admin/pages/edit.html +32 -0
  51. skrift/templates/admin/pages/list.html +62 -0
  52. skrift/templates/admin/settings/site.html +32 -0
  53. skrift/templates/admin/users/list.html +58 -0
  54. skrift/templates/admin/users/roles.html +42 -0
  55. skrift/templates/auth/dummy_login.html +102 -0
  56. skrift/templates/auth/login.html +139 -0
  57. skrift/templates/base.html +52 -0
  58. skrift/templates/error-404.html +19 -0
  59. skrift/templates/error-500.html +19 -0
  60. skrift/templates/error.html +19 -0
  61. skrift/templates/index.html +9 -0
  62. skrift/templates/page.html +26 -0
  63. skrift/templates/setup/admin.html +24 -0
  64. skrift/templates/setup/auth.html +110 -0
  65. skrift/templates/setup/base.html +407 -0
  66. skrift/templates/setup/complete.html +17 -0
  67. skrift/templates/setup/configuring.html +158 -0
  68. skrift/templates/setup/database.html +125 -0
  69. skrift/templates/setup/restart.html +28 -0
  70. skrift/templates/setup/site.html +39 -0
  71. skrift-0.1.0a12.dist-info/METADATA +235 -0
  72. skrift-0.1.0a12.dist-info/RECORD +74 -0
  73. skrift-0.1.0a12.dist-info/WHEEL +4 -0
  74. 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
@@ -0,0 +1,4 @@
1
+ from skrift.controllers.auth import AuthController
2
+ from skrift.controllers.web import WebController
3
+
4
+ __all__ = ["AuthController", "WebController"]