vibetuner 2.18.1__py3-none-any.whl → 2.30.1__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.
Potentially problematic release.
This version of vibetuner might be problematic. Click here for more details.
- vibetuner/cli/__init__.py +48 -3
- vibetuner/cli/run.py +2 -2
- vibetuner/cli/scaffold.py +25 -0
- vibetuner/config.py +13 -2
- vibetuner/frontend/__init__.py +28 -5
- vibetuner/frontend/lifespan.py +8 -2
- vibetuner/frontend/middleware.py +0 -4
- vibetuner/frontend/routes/debug.py +41 -31
- vibetuner/frontend/templates.py +47 -2
- vibetuner/mongo.py +15 -0
- vibetuner/paths.py +34 -10
- vibetuner/services/blob.py +31 -45
- vibetuner/services/email.py +14 -15
- vibetuner/services/s3_storage.py +454 -0
- vibetuner/tasks/__init__.py +0 -2
- vibetuner/tasks/lifespan.py +28 -0
- vibetuner/tasks/worker.py +2 -9
- vibetuner/versioning.py +6 -2
- {vibetuner-2.18.1.dist-info → vibetuner-2.30.1.dist-info}/METADATA +18 -15
- {vibetuner-2.18.1.dist-info → vibetuner-2.30.1.dist-info}/RECORD +22 -22
- {vibetuner-2.18.1.dist-info → vibetuner-2.30.1.dist-info}/WHEEL +1 -1
- vibetuner/frontend/context.py +0 -10
- vibetuner/tasks/context.py +0 -34
- {vibetuner-2.18.1.dist-info → vibetuner-2.30.1.dist-info}/entry_points.txt +0 -0
vibetuner/cli/__init__.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# ABOUTME: Core CLI setup with AsyncTyper wrapper and base configuration
|
|
2
2
|
# ABOUTME: Provides main CLI entry point and logging configuration
|
|
3
|
+
import importlib.metadata
|
|
3
4
|
import inspect
|
|
4
5
|
from functools import partial, wraps
|
|
5
6
|
from importlib import import_module
|
|
@@ -7,10 +8,11 @@ from importlib import import_module
|
|
|
7
8
|
import asyncer
|
|
8
9
|
import typer
|
|
9
10
|
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
10
12
|
|
|
11
13
|
from vibetuner.cli.run import run_app
|
|
12
14
|
from vibetuner.cli.scaffold import scaffold_app
|
|
13
|
-
from vibetuner.logging import LogLevel, setup_logging
|
|
15
|
+
from vibetuner.logging import LogLevel, logger, setup_logging
|
|
14
16
|
|
|
15
17
|
|
|
16
18
|
console = Console()
|
|
@@ -69,11 +71,54 @@ def callback(log_level: LogLevel | None = LOG_LEVEL_OPTION) -> None:
|
|
|
69
71
|
setup_logging(level=log_level)
|
|
70
72
|
|
|
71
73
|
|
|
74
|
+
@app.command()
|
|
75
|
+
def version(
|
|
76
|
+
show_app: bool = typer.Option(
|
|
77
|
+
False,
|
|
78
|
+
"--app",
|
|
79
|
+
"-a",
|
|
80
|
+
help="Show app settings version even if not in a project directory",
|
|
81
|
+
),
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Show version information."""
|
|
84
|
+
try:
|
|
85
|
+
# Get vibetuner package version
|
|
86
|
+
vibetuner_version = importlib.metadata.version("vibetuner")
|
|
87
|
+
except importlib.metadata.PackageNotFoundError:
|
|
88
|
+
vibetuner_version = "unknown"
|
|
89
|
+
|
|
90
|
+
# Create table for nice display
|
|
91
|
+
table = Table(title="Version Information")
|
|
92
|
+
table.add_column("Component", style="cyan", no_wrap=True)
|
|
93
|
+
table.add_column("Version", style="green", no_wrap=True)
|
|
94
|
+
|
|
95
|
+
# Always show vibetuner package version
|
|
96
|
+
table.add_row("vibetuner package", vibetuner_version)
|
|
97
|
+
|
|
98
|
+
# Show app version if requested or if in a project
|
|
99
|
+
try:
|
|
100
|
+
from vibetuner.config import CoreConfiguration
|
|
101
|
+
|
|
102
|
+
settings = CoreConfiguration()
|
|
103
|
+
table.add_row(f"{settings.project.project_name} settings", settings.version)
|
|
104
|
+
except Exception:
|
|
105
|
+
if show_app:
|
|
106
|
+
table.add_row("app settings", "not in project directory")
|
|
107
|
+
# else: don't show app version if not in project and not requested
|
|
108
|
+
|
|
109
|
+
console.print(table)
|
|
110
|
+
|
|
111
|
+
|
|
72
112
|
app.add_typer(run_app, name="run")
|
|
73
113
|
app.add_typer(scaffold_app, name="scaffold")
|
|
74
114
|
|
|
75
115
|
try:
|
|
76
116
|
import_module("app.cli")
|
|
77
|
-
except
|
|
117
|
+
except ModuleNotFoundError:
|
|
118
|
+
# Silent pass for missing app.cli module (expected in some projects)
|
|
78
119
|
pass
|
|
79
|
-
|
|
120
|
+
except ImportError as e:
|
|
121
|
+
# Log warning for any import error (including syntax errors, missing dependencies, etc.)
|
|
122
|
+
logger.warning(
|
|
123
|
+
f"Failed to import app.cli: {e}. User CLI commands will not be available."
|
|
124
|
+
)
|
vibetuner/cli/run.py
CHANGED
|
@@ -47,7 +47,7 @@ def dev(
|
|
|
47
47
|
|
|
48
48
|
# Call streaq programmatically
|
|
49
49
|
streaq_main(
|
|
50
|
-
worker_path="
|
|
50
|
+
worker_path="vibetuner.tasks.worker.worker",
|
|
51
51
|
workers=1,
|
|
52
52
|
reload=True,
|
|
53
53
|
verbose=True,
|
|
@@ -123,7 +123,7 @@ def prod(
|
|
|
123
123
|
|
|
124
124
|
# Call streaq programmatically
|
|
125
125
|
streaq_main(
|
|
126
|
-
worker_path="
|
|
126
|
+
worker_path="vibetuner.tasks.worker.worker",
|
|
127
127
|
workers=workers_count,
|
|
128
128
|
reload=False,
|
|
129
129
|
verbose=False,
|
vibetuner/cli/scaffold.py
CHANGED
|
@@ -185,3 +185,28 @@ def update(
|
|
|
185
185
|
except Exception as e:
|
|
186
186
|
console.print(f"[red]Error updating project: {e}[/red]")
|
|
187
187
|
raise typer.Exit(code=1) from None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@scaffold_app.command(name="link")
|
|
191
|
+
def link(
|
|
192
|
+
target: Annotated[
|
|
193
|
+
Path,
|
|
194
|
+
typer.Argument(
|
|
195
|
+
help="Path where the 'core' symlink should be created or updated",
|
|
196
|
+
),
|
|
197
|
+
],
|
|
198
|
+
) -> None:
|
|
199
|
+
"""Create or update a 'core' symlink to the package templates directory.
|
|
200
|
+
|
|
201
|
+
This command creates a symlink from the specified target path to the core
|
|
202
|
+
templates directory in the vibetuner package. It is used during development
|
|
203
|
+
to enable Tailwind and other build tools to scan core templates.
|
|
204
|
+
|
|
205
|
+
Examples:
|
|
206
|
+
|
|
207
|
+
# Create symlink in templates/core
|
|
208
|
+
vibetuner scaffold link templates/core
|
|
209
|
+
"""
|
|
210
|
+
from vibetuner.paths import create_core_templates_symlink
|
|
211
|
+
|
|
212
|
+
create_core_templates_symlink(target)
|
vibetuner/config.py
CHANGED
|
@@ -2,7 +2,7 @@ import base64
|
|
|
2
2
|
import hashlib
|
|
3
3
|
from datetime import datetime
|
|
4
4
|
from functools import cached_property
|
|
5
|
-
from typing import Annotated
|
|
5
|
+
from typing import Annotated, Literal
|
|
6
6
|
|
|
7
7
|
import yaml
|
|
8
8
|
from pydantic import (
|
|
@@ -100,8 +100,10 @@ class CoreConfiguration(BaseSettings):
|
|
|
100
100
|
project: ProjectConfiguration = ProjectConfiguration.from_project_config()
|
|
101
101
|
|
|
102
102
|
debug: bool = False
|
|
103
|
+
environment: Literal["dev", "prod"] = "dev"
|
|
103
104
|
version: str = version
|
|
104
105
|
session_key: SecretStr = SecretStr("ct-!secret-must-change-me")
|
|
106
|
+
debug_access_token: str | None = None
|
|
105
107
|
|
|
106
108
|
# Database and Cache URLs
|
|
107
109
|
mongodb_url: MongoDsn = MongoDsn("mongodb://localhost:27017")
|
|
@@ -132,6 +134,16 @@ class CoreConfiguration(BaseSettings):
|
|
|
132
134
|
def mongo_dbname(self) -> str:
|
|
133
135
|
return self.project.project_slug
|
|
134
136
|
|
|
137
|
+
@cached_property
|
|
138
|
+
def redis_key_prefix(self) -> str:
|
|
139
|
+
"""Returns the Redis key prefix for namespacing all Redis keys by project and environment.
|
|
140
|
+
|
|
141
|
+
Format: "{project_slug}:{env}:" for dev, "{project_slug}:" for prod.
|
|
142
|
+
"""
|
|
143
|
+
if self.environment == "dev":
|
|
144
|
+
return f"{self.project.project_slug}:dev:"
|
|
145
|
+
return f"{self.project.project_slug}:"
|
|
146
|
+
|
|
135
147
|
model_config = SettingsConfigDict(
|
|
136
148
|
case_sensitive=False, extra="ignore", env_file=".env"
|
|
137
149
|
)
|
|
@@ -141,4 +153,3 @@ settings = CoreConfiguration()
|
|
|
141
153
|
|
|
142
154
|
|
|
143
155
|
logger.info("Configuration loaded for project: {}", settings.project.project_name)
|
|
144
|
-
logger.info("Configuration loaded for project: {}", settings.model_dump())
|
vibetuner/frontend/__init__.py
CHANGED
|
@@ -4,10 +4,12 @@ from fastapi import APIRouter, Depends as Depends, FastAPI, Request
|
|
|
4
4
|
from fastapi.responses import HTMLResponse, RedirectResponse
|
|
5
5
|
from fastapi.staticfiles import StaticFiles
|
|
6
6
|
|
|
7
|
+
import vibetuner.frontend.lifespan as lifespan_module
|
|
7
8
|
from vibetuner import paths
|
|
9
|
+
from vibetuner.logging import logger
|
|
8
10
|
|
|
9
11
|
from .deps import LangDep as LangDep, MagicCookieDep as MagicCookieDep
|
|
10
|
-
from .lifespan import ctx
|
|
12
|
+
from .lifespan import ctx
|
|
11
13
|
from .middleware import middlewares
|
|
12
14
|
from .routes import auth, debug, health, language, meta, user
|
|
13
15
|
from .templates import render_template
|
|
@@ -21,15 +23,35 @@ def register_router(router: APIRouter) -> None:
|
|
|
21
23
|
|
|
22
24
|
|
|
23
25
|
try:
|
|
24
|
-
import app.frontend.oauth as _app_oauth #
|
|
25
|
-
import app.frontend.routes as _app_routes #
|
|
26
|
+
import app.frontend.oauth as _app_oauth # type: ignore[unresolved-import] # noqa: F401
|
|
27
|
+
import app.frontend.routes as _app_routes # type: ignore[unresolved-import] # noqa: F401
|
|
26
28
|
|
|
27
29
|
# Register OAuth routes after providers are registered
|
|
28
30
|
from .routes.auth import register_oauth_routes
|
|
29
31
|
|
|
30
32
|
register_oauth_routes()
|
|
31
|
-
except
|
|
33
|
+
except ModuleNotFoundError:
|
|
34
|
+
# Silent pass for missing app.frontend.oauth or app.frontend.routes modules (expected in some projects)
|
|
32
35
|
pass
|
|
36
|
+
except ImportError as e:
|
|
37
|
+
# Log warning for any import error (including syntax errors, missing dependencies, etc.)
|
|
38
|
+
logger.warning(
|
|
39
|
+
f"Failed to import app.frontend.oauth or app.frontend.routes: {e}. OAuth and custom routes will not be available."
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
try:
|
|
43
|
+
from app.frontend.middleware import (
|
|
44
|
+
middlewares as app_middlewares, # type: ignore[unresolved-import]
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
middlewares.extend(app_middlewares)
|
|
48
|
+
except ModuleNotFoundError:
|
|
49
|
+
pass
|
|
50
|
+
except ImportError as e:
|
|
51
|
+
# Log warning for any import error (including syntax errors, missing dependencies, etc.)
|
|
52
|
+
logger.warning(
|
|
53
|
+
f"Failed to import app.frontend.middleware: {e}. Additional middlewares will not be available."
|
|
54
|
+
)
|
|
33
55
|
|
|
34
56
|
|
|
35
57
|
dependencies: list[Any] = [
|
|
@@ -38,7 +60,7 @@ dependencies: list[Any] = [
|
|
|
38
60
|
|
|
39
61
|
app = FastAPI(
|
|
40
62
|
debug=ctx.DEBUG,
|
|
41
|
-
lifespan=lifespan,
|
|
63
|
+
lifespan=lifespan_module.lifespan,
|
|
42
64
|
docs_url=None,
|
|
43
65
|
redoc_url=None,
|
|
44
66
|
openapi_url=None,
|
|
@@ -95,5 +117,6 @@ def default_index(request: Request) -> HTMLResponse:
|
|
|
95
117
|
return render_template("index.html.jinja", request)
|
|
96
118
|
|
|
97
119
|
|
|
120
|
+
app.include_router(debug.auth_router)
|
|
98
121
|
app.include_router(debug.router)
|
|
99
122
|
app.include_router(health.router)
|
vibetuner/frontend/lifespan.py
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
from contextlib import asynccontextmanager
|
|
2
|
+
from typing import AsyncGenerator
|
|
2
3
|
|
|
3
4
|
from fastapi import FastAPI
|
|
4
5
|
|
|
@@ -10,7 +11,7 @@ from .hotreload import hotreload
|
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
@asynccontextmanager
|
|
13
|
-
async def base_lifespan(app: FastAPI):
|
|
14
|
+
async def base_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|
14
15
|
logger.info("Vibetuner frontend starting")
|
|
15
16
|
if ctx.DEBUG:
|
|
16
17
|
await hotreload.startup()
|
|
@@ -27,5 +28,10 @@ async def base_lifespan(app: FastAPI):
|
|
|
27
28
|
|
|
28
29
|
try:
|
|
29
30
|
from app.frontend.lifespan import lifespan # ty: ignore
|
|
30
|
-
except
|
|
31
|
+
except ModuleNotFoundError:
|
|
32
|
+
# Silent pass for missing app.frontend.lifespan module (expected in some projects)
|
|
33
|
+
lifespan = base_lifespan
|
|
34
|
+
except ImportError as e:
|
|
35
|
+
# Log warning for any import error (including syntax errors, missing dependencies, etc.)
|
|
36
|
+
logger.warning(f"Failed to import app.frontend.lifespan: {e}. Using base lifespan.")
|
|
31
37
|
lifespan = base_lifespan
|
vibetuner/frontend/middleware.py
CHANGED
|
@@ -126,7 +126,6 @@ class AuthBackend(AuthenticationBackend):
|
|
|
126
126
|
return None
|
|
127
127
|
|
|
128
128
|
|
|
129
|
-
# Until this line
|
|
130
129
|
middlewares: list[Middleware] = [
|
|
131
130
|
Middleware(TrustedHostMiddleware),
|
|
132
131
|
Middleware(ForwardedProtocolMiddleware),
|
|
@@ -145,7 +144,4 @@ middlewares: list[Middleware] = [
|
|
|
145
144
|
),
|
|
146
145
|
Middleware(AdjustLangCookieMiddleware),
|
|
147
146
|
Middleware(AuthenticationMiddleware, backend=AuthBackend()),
|
|
148
|
-
# Add your middleware below this line
|
|
149
147
|
]
|
|
150
|
-
|
|
151
|
-
# EOF
|
|
@@ -3,13 +3,13 @@ from fastapi import (
|
|
|
3
3
|
Depends,
|
|
4
4
|
HTTPException,
|
|
5
5
|
Request,
|
|
6
|
-
Response,
|
|
7
6
|
)
|
|
8
7
|
from fastapi.responses import (
|
|
9
8
|
HTMLResponse,
|
|
10
9
|
RedirectResponse,
|
|
11
10
|
)
|
|
12
11
|
|
|
12
|
+
from vibetuner.config import settings
|
|
13
13
|
from vibetuner.context import ctx
|
|
14
14
|
from vibetuner.models import UserModel
|
|
15
15
|
from vibetuner.models.registry import get_all_models
|
|
@@ -18,32 +18,41 @@ from ..deps import MAGIC_COOKIE_NAME
|
|
|
18
18
|
from ..templates import render_template
|
|
19
19
|
|
|
20
20
|
|
|
21
|
-
def check_debug_access(request: Request
|
|
21
|
+
def check_debug_access(request: Request):
|
|
22
22
|
"""Check if debug routes should be accessible."""
|
|
23
23
|
# Always allow in development mode
|
|
24
24
|
if ctx.DEBUG:
|
|
25
25
|
return True
|
|
26
26
|
|
|
27
|
-
# In production, require
|
|
28
|
-
if
|
|
29
|
-
|
|
27
|
+
# In production, require magic cookie
|
|
28
|
+
if MAGIC_COOKIE_NAME not in request.cookies:
|
|
29
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
30
|
+
if request.cookies[MAGIC_COOKIE_NAME] != "granted":
|
|
31
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
raise HTTPException(status_code=404, detail="Not found")
|
|
33
|
+
return True
|
|
33
34
|
|
|
34
35
|
|
|
35
|
-
router
|
|
36
|
+
# Unprotected router for token-based debug access
|
|
37
|
+
auth_router = APIRouter()
|
|
36
38
|
|
|
37
39
|
|
|
38
|
-
@
|
|
39
|
-
def
|
|
40
|
-
|
|
40
|
+
@auth_router.get("/_unlock-debug")
|
|
41
|
+
def unlock_debug_access(token: str | None = None):
|
|
42
|
+
"""Grant debug access by setting the magic cookie.
|
|
41
43
|
|
|
44
|
+
In DEBUG mode, no token is required.
|
|
45
|
+
In production, the token must match DEBUG_ACCESS_TOKEN.
|
|
46
|
+
If DEBUG_ACCESS_TOKEN is not configured, debug access is disabled in production.
|
|
47
|
+
"""
|
|
48
|
+
if not ctx.DEBUG:
|
|
49
|
+
# In production, validate token
|
|
50
|
+
if settings.debug_access_token is None:
|
|
51
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
52
|
+
if token is None or token != settings.debug_access_token:
|
|
53
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
42
54
|
|
|
43
|
-
|
|
44
|
-
def set_magic_cookie(response: Response):
|
|
45
|
-
"""Set the magic access cookie."""
|
|
46
|
-
response = RedirectResponse(url="/", status_code=302)
|
|
55
|
+
response = RedirectResponse(url="/debug", status_code=302)
|
|
47
56
|
response.set_cookie(
|
|
48
57
|
key=MAGIC_COOKIE_NAME,
|
|
49
58
|
value="granted",
|
|
@@ -55,14 +64,23 @@ def set_magic_cookie(response: Response):
|
|
|
55
64
|
return response
|
|
56
65
|
|
|
57
66
|
|
|
58
|
-
@
|
|
59
|
-
def
|
|
60
|
-
"""
|
|
67
|
+
@auth_router.get("/_lock-debug")
|
|
68
|
+
def lock_debug_access():
|
|
69
|
+
"""Revoke debug access by removing the magic cookie."""
|
|
61
70
|
response = RedirectResponse(url="/", status_code=302)
|
|
62
71
|
response.delete_cookie(key=MAGIC_COOKIE_NAME)
|
|
63
72
|
return response
|
|
64
73
|
|
|
65
74
|
|
|
75
|
+
# Protected router for debug endpoints requiring cookie auth
|
|
76
|
+
router = APIRouter(prefix="/debug", dependencies=[Depends(check_debug_access)])
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@router.get("/", response_class=HTMLResponse)
|
|
80
|
+
def debug_index(request: Request):
|
|
81
|
+
return render_template("debug/index.html.jinja", request)
|
|
82
|
+
|
|
83
|
+
|
|
66
84
|
@router.get("/version", response_class=HTMLResponse)
|
|
67
85
|
def debug_version(request: Request):
|
|
68
86
|
return render_template("debug/version.html.jinja", request)
|
|
@@ -136,20 +154,11 @@ def _is_beanie_link_schema(option: dict) -> bool:
|
|
|
136
154
|
|
|
137
155
|
def _infer_link_target_from_field_name(field_name: str) -> str:
|
|
138
156
|
"""Infer the target model type from field name patterns."""
|
|
139
|
-
#
|
|
157
|
+
# Core vibetuner models
|
|
140
158
|
patterns = {
|
|
141
159
|
"oauth_accounts": "OAuthAccountModel",
|
|
142
|
-
"accounts": "AccountModel",
|
|
143
160
|
"users": "UserModel",
|
|
144
161
|
"user": "UserModel",
|
|
145
|
-
"stations": "StationModel",
|
|
146
|
-
"station": "StationModel",
|
|
147
|
-
"rundowns": "RundownModel",
|
|
148
|
-
"rundown": "RundownModel",
|
|
149
|
-
"fillers": "FillerModel",
|
|
150
|
-
"filler": "FillerModel",
|
|
151
|
-
"voices": "VoiceModel",
|
|
152
|
-
"voice": "VoiceModel",
|
|
153
162
|
"blobs": "BlobModel",
|
|
154
163
|
"blob": "BlobModel",
|
|
155
164
|
}
|
|
@@ -374,10 +383,13 @@ async def debug_users(request: Request):
|
|
|
374
383
|
)
|
|
375
384
|
|
|
376
385
|
|
|
386
|
+
# The following endpoints are restricted to DEBUG mode only (no production access).
|
|
387
|
+
# These are dangerous operations that could compromise security if allowed in production.
|
|
388
|
+
|
|
389
|
+
|
|
377
390
|
@router.post("/impersonate/{user_id}")
|
|
378
391
|
async def debug_impersonate_user(request: Request, user_id: str):
|
|
379
392
|
"""Impersonate a user by setting their ID in the session."""
|
|
380
|
-
# Double check debug mode for security
|
|
381
393
|
if not ctx.DEBUG:
|
|
382
394
|
raise HTTPException(status_code=404, detail="Not found")
|
|
383
395
|
|
|
@@ -395,7 +407,6 @@ async def debug_impersonate_user(request: Request, user_id: str):
|
|
|
395
407
|
@router.post("/stop-impersonation")
|
|
396
408
|
async def debug_stop_impersonation(request: Request):
|
|
397
409
|
"""Stop impersonating and clear user session."""
|
|
398
|
-
# Double check debug mode for security
|
|
399
410
|
if not ctx.DEBUG:
|
|
400
411
|
raise HTTPException(status_code=404, detail="Not found")
|
|
401
412
|
|
|
@@ -406,7 +417,6 @@ async def debug_stop_impersonation(request: Request):
|
|
|
406
417
|
@router.get("/clear-session")
|
|
407
418
|
async def debug_clear_session(request: Request):
|
|
408
419
|
"""Clear all session data to fix corrupted sessions."""
|
|
409
|
-
# Double check debug mode for security
|
|
410
420
|
if not ctx.DEBUG:
|
|
411
421
|
raise HTTPException(status_code=404, detail="Not found")
|
|
412
422
|
|
vibetuner/frontend/templates.py
CHANGED
|
@@ -7,7 +7,7 @@ from starlette.responses import HTMLResponse
|
|
|
7
7
|
from starlette_babel import gettext_lazy as _, gettext_lazy as ngettext
|
|
8
8
|
from starlette_babel.contrib.jinja import configure_jinja_env
|
|
9
9
|
|
|
10
|
-
from vibetuner.context import
|
|
10
|
+
from vibetuner.context import ctx as data_ctx
|
|
11
11
|
from vibetuner.paths import frontend_templates
|
|
12
12
|
from vibetuner.templates import render_static_template
|
|
13
13
|
from vibetuner.time import age_in_timedelta
|
|
@@ -17,9 +17,36 @@ from .hotreload import hotreload
|
|
|
17
17
|
|
|
18
18
|
__all__ = [
|
|
19
19
|
"render_static_template",
|
|
20
|
+
"register_filter",
|
|
20
21
|
]
|
|
21
22
|
|
|
22
|
-
|
|
23
|
+
|
|
24
|
+
_filter_registry: dict[str, Any] = {}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def register_filter(name: str | None = None):
|
|
28
|
+
"""Decorator to register a custom Jinja2 filter.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
name: Optional custom name for the filter. If not provided,
|
|
32
|
+
uses the function name.
|
|
33
|
+
|
|
34
|
+
Usage:
|
|
35
|
+
@register_filter()
|
|
36
|
+
def my_filter(value):
|
|
37
|
+
return value.upper()
|
|
38
|
+
|
|
39
|
+
@register_filter("custom_name")
|
|
40
|
+
def another_filter(value):
|
|
41
|
+
return value.lower()
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def decorator(func):
|
|
45
|
+
filter_name = name or func.__name__
|
|
46
|
+
_filter_registry[filter_name] = func
|
|
47
|
+
return func
|
|
48
|
+
|
|
49
|
+
return decorator
|
|
23
50
|
|
|
24
51
|
|
|
25
52
|
def timeago(dt):
|
|
@@ -173,4 +200,22 @@ jinja_env.filters["format_datetime"] = format_datetime
|
|
|
173
200
|
jinja_env.filters["format_duration"] = format_duration
|
|
174
201
|
jinja_env.filters["duration"] = format_duration
|
|
175
202
|
|
|
203
|
+
# Import user-defined filters to trigger registration
|
|
204
|
+
try:
|
|
205
|
+
import app.frontend.templates as _app_templates # type: ignore[import-not-found] # noqa: F401
|
|
206
|
+
except ModuleNotFoundError:
|
|
207
|
+
# Silent pass - templates module is optional
|
|
208
|
+
pass
|
|
209
|
+
except ImportError as e:
|
|
210
|
+
from vibetuner.logging import logger
|
|
211
|
+
|
|
212
|
+
logger.warning(
|
|
213
|
+
f"Failed to import app.frontend.templates: {e}. Custom filters will not be available."
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
# Apply all registered custom filters
|
|
217
|
+
for filter_name, filter_func in _filter_registry.items():
|
|
218
|
+
jinja_env.filters[filter_name] = filter_func
|
|
219
|
+
|
|
220
|
+
# Configure Jinja environment after all filters are registered
|
|
176
221
|
configure_jinja_env(jinja_env)
|
vibetuner/mongo.py
CHANGED
|
@@ -1,13 +1,28 @@
|
|
|
1
|
+
from importlib import import_module
|
|
2
|
+
|
|
1
3
|
from beanie import init_beanie
|
|
2
4
|
from pymongo import AsyncMongoClient
|
|
3
5
|
|
|
4
6
|
from vibetuner.config import settings
|
|
7
|
+
from vibetuner.logging import logger
|
|
5
8
|
from vibetuner.models.registry import get_all_models
|
|
6
9
|
|
|
7
10
|
|
|
8
11
|
async def init_models() -> None:
|
|
9
12
|
"""Initialize MongoDB connection and register all Beanie models."""
|
|
10
13
|
|
|
14
|
+
# Try to import user models to trigger their registration
|
|
15
|
+
try:
|
|
16
|
+
import_module("app.models")
|
|
17
|
+
except ModuleNotFoundError:
|
|
18
|
+
# Silent pass for missing app.models module (expected in some projects)
|
|
19
|
+
pass
|
|
20
|
+
except ImportError as e:
|
|
21
|
+
# Log warning for any import error (including syntax errors, missing dependencies, etc.)
|
|
22
|
+
logger.warning(
|
|
23
|
+
f"Failed to import app.models: {e}. User models will not be registered."
|
|
24
|
+
)
|
|
25
|
+
|
|
11
26
|
client: AsyncMongoClient = AsyncMongoClient(
|
|
12
27
|
host=str(settings.mongodb_url),
|
|
13
28
|
compressors=["zstd"],
|
vibetuner/paths.py
CHANGED
|
@@ -5,6 +5,8 @@ from typing import Self
|
|
|
5
5
|
from pydantic import computed_field, model_validator
|
|
6
6
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
7
7
|
|
|
8
|
+
from vibetuner.logging import logger
|
|
9
|
+
|
|
8
10
|
|
|
9
11
|
# Package-relative paths (for bundled templates in the vibetuner package)
|
|
10
12
|
_package_files = files("vibetuner")
|
|
@@ -22,6 +24,38 @@ def _get_package_templates_path() -> Path:
|
|
|
22
24
|
) from None
|
|
23
25
|
|
|
24
26
|
|
|
27
|
+
def create_core_templates_symlink(target: Path) -> None:
|
|
28
|
+
"""Create or update a 'core' symlink pointing to the package templates directory."""
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
source = _get_package_templates_path().resolve()
|
|
32
|
+
except RuntimeError as e:
|
|
33
|
+
logger.error(f"Cannot create symlink: {e}")
|
|
34
|
+
return
|
|
35
|
+
|
|
36
|
+
# Case 1: target is already a symlink → check if it needs updating
|
|
37
|
+
if target.is_symlink():
|
|
38
|
+
if target.resolve() != source:
|
|
39
|
+
target.unlink()
|
|
40
|
+
target.symlink_to(source, target_is_directory=True)
|
|
41
|
+
logger.info(f"Updated symlink '{target}' → '{source}'")
|
|
42
|
+
else:
|
|
43
|
+
logger.debug(f"Symlink '{target}' already points to '{source}'")
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
# Case 2: target does not exist → create symlink
|
|
47
|
+
if not target.exists():
|
|
48
|
+
target.symlink_to(source, target_is_directory=True)
|
|
49
|
+
logger.info(f"Created symlink '{target}' → '{source}'")
|
|
50
|
+
return
|
|
51
|
+
|
|
52
|
+
# Case 3: exists but is not a symlink → error
|
|
53
|
+
logger.error(f"Cannot create symlink: '{target}' exists and is not a symlink.")
|
|
54
|
+
raise FileExistsError(
|
|
55
|
+
f"Cannot create symlink: '{target}' exists and is not a symlink."
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
25
59
|
# Package templates always available
|
|
26
60
|
package_templates = _get_package_templates_path()
|
|
27
61
|
core_templates = package_templates # Alias for backwards compatibility
|
|
@@ -158,10 +192,6 @@ class PathSettings(BaseSettings):
|
|
|
158
192
|
paths.append(package_templates / "markdown")
|
|
159
193
|
return paths
|
|
160
194
|
|
|
161
|
-
def set_root(self, project_root: Path) -> None:
|
|
162
|
-
"""Explicitly set project root (overrides auto-detection)."""
|
|
163
|
-
self.root = project_root
|
|
164
|
-
|
|
165
195
|
def to_template_path_list(self, path: Path) -> list[Path]:
|
|
166
196
|
"""Convert path to list with fallback."""
|
|
167
197
|
return [path, path / self.fallback_path]
|
|
@@ -191,12 +221,6 @@ class PathSettings(BaseSettings):
|
|
|
191
221
|
_settings = PathSettings()
|
|
192
222
|
|
|
193
223
|
|
|
194
|
-
# Backwards-compatible module-level API
|
|
195
|
-
def set_project_root(project_root: Path) -> None:
|
|
196
|
-
"""Set the project root directory explicitly."""
|
|
197
|
-
_settings.set_root(project_root)
|
|
198
|
-
|
|
199
|
-
|
|
200
224
|
def to_template_path_list(path: Path) -> list[Path]:
|
|
201
225
|
"""Convert path to list with fallback."""
|
|
202
226
|
return _settings.to_template_path_list(path)
|