vibetuner 2.26.6__py3-none-any.whl → 2.33.0__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.
- vibetuner/cli/__init__.py +0 -17
- vibetuner/cli/run.py +4 -2
- vibetuner/cli/scaffold.py +52 -0
- vibetuner/config.py +15 -1
- vibetuner/frontend/__init__.py +17 -2
- vibetuner/frontend/middleware.py +0 -4
- vibetuner/frontend/routes/debug.py +41 -31
- vibetuner/frontend/templates.py +47 -2
- vibetuner/paths.py +16 -13
- vibetuner/services/blob.py +31 -45
- vibetuner/services/email.py +14 -15
- vibetuner/services/s3_storage.py +454 -0
- vibetuner/tasks/worker.py +2 -5
- {vibetuner-2.26.6.dist-info → vibetuner-2.33.0.dist-info}/METADATA +16 -13
- {vibetuner-2.26.6.dist-info → vibetuner-2.33.0.dist-info}/RECORD +17 -16
- {vibetuner-2.26.6.dist-info → vibetuner-2.33.0.dist-info}/WHEEL +1 -1
- {vibetuner-2.26.6.dist-info → vibetuner-2.33.0.dist-info}/entry_points.txt +0 -0
vibetuner/cli/__init__.py
CHANGED
|
@@ -4,8 +4,6 @@ import importlib.metadata
|
|
|
4
4
|
import inspect
|
|
5
5
|
from functools import partial, wraps
|
|
6
6
|
from importlib import import_module
|
|
7
|
-
from pathlib import Path
|
|
8
|
-
from typing import Annotated
|
|
9
7
|
|
|
10
8
|
import asyncer
|
|
11
9
|
import typer
|
|
@@ -111,21 +109,6 @@ def version(
|
|
|
111
109
|
console.print(table)
|
|
112
110
|
|
|
113
111
|
|
|
114
|
-
@app.command()
|
|
115
|
-
def core_template_symlink(
|
|
116
|
-
target: Annotated[
|
|
117
|
-
Path,
|
|
118
|
-
typer.Argument(
|
|
119
|
-
help="Path where the 'core' symlink should be created or updated",
|
|
120
|
-
),
|
|
121
|
-
],
|
|
122
|
-
) -> None:
|
|
123
|
-
"""Create or update a 'core' symlink to the package templates directory."""
|
|
124
|
-
from vibetuner.paths import create_core_templates_symlink
|
|
125
|
-
|
|
126
|
-
create_core_templates_symlink(target)
|
|
127
|
-
|
|
128
|
-
|
|
129
112
|
app.add_typer(run_app, name="run")
|
|
130
113
|
app.add_typer(scaffold_app, name="scaffold")
|
|
131
114
|
|
vibetuner/cli/run.py
CHANGED
|
@@ -109,7 +109,9 @@ def prod(
|
|
|
109
109
|
),
|
|
110
110
|
) -> None:
|
|
111
111
|
"""Run in production mode (frontend or worker)."""
|
|
112
|
-
|
|
112
|
+
from vibetuner.config import settings
|
|
113
|
+
|
|
114
|
+
os.environ["ENVIRONMENT"] = "prod"
|
|
113
115
|
|
|
114
116
|
if service == "worker":
|
|
115
117
|
# Worker mode
|
|
@@ -126,7 +128,7 @@ def prod(
|
|
|
126
128
|
worker_path="vibetuner.tasks.worker.worker",
|
|
127
129
|
workers=workers_count,
|
|
128
130
|
reload=False,
|
|
129
|
-
verbose=
|
|
131
|
+
verbose=settings.debug,
|
|
130
132
|
web=True,
|
|
131
133
|
host="0.0.0.0", # noqa: S104
|
|
132
134
|
port=worker_port,
|
vibetuner/cli/scaffold.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# ABOUTME: Scaffolding commands for creating new projects from the vibetuner template
|
|
2
2
|
# ABOUTME: Uses Copier to generate FastAPI+MongoDB+HTMX projects with interactive prompts
|
|
3
|
+
import shutil
|
|
3
4
|
from pathlib import Path
|
|
4
5
|
from typing import Annotated
|
|
5
6
|
|
|
@@ -7,9 +8,28 @@ import copier
|
|
|
7
8
|
import typer
|
|
8
9
|
from rich.console import Console
|
|
9
10
|
|
|
11
|
+
from vibetuner.paths import package_templates
|
|
12
|
+
|
|
10
13
|
|
|
11
14
|
console = Console()
|
|
12
15
|
|
|
16
|
+
|
|
17
|
+
def _copy_core_templates(destination: Path) -> None:
|
|
18
|
+
"""Copy vibetuner core templates to the project's core-templates directory.
|
|
19
|
+
|
|
20
|
+
This enables Docker builds to run frontend builds in parallel with Python
|
|
21
|
+
dependency installation by having templates available in the build context.
|
|
22
|
+
"""
|
|
23
|
+
source = package_templates / "frontend"
|
|
24
|
+
dest = destination / "core-templates"
|
|
25
|
+
|
|
26
|
+
if dest.exists():
|
|
27
|
+
shutil.rmtree(dest)
|
|
28
|
+
|
|
29
|
+
shutil.copytree(source, dest)
|
|
30
|
+
console.print("[dim]Copied core templates to core-templates/[/dim]")
|
|
31
|
+
|
|
32
|
+
|
|
13
33
|
scaffold_app = typer.Typer(
|
|
14
34
|
help="Create new projects from the vibetuner template", no_args_is_help=True
|
|
15
35
|
)
|
|
@@ -185,3 +205,35 @@ def update(
|
|
|
185
205
|
except Exception as e:
|
|
186
206
|
console.print(f"[red]Error updating project: {e}[/red]")
|
|
187
207
|
raise typer.Exit(code=1) from None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
@scaffold_app.command(name="copy-core-templates")
|
|
211
|
+
def copy_core_templates(
|
|
212
|
+
destination: Annotated[
|
|
213
|
+
Path | None,
|
|
214
|
+
typer.Argument(
|
|
215
|
+
help="Path to the project directory (defaults to current directory)",
|
|
216
|
+
),
|
|
217
|
+
] = None,
|
|
218
|
+
) -> None:
|
|
219
|
+
"""Copy vibetuner core templates to the project's core-templates directory.
|
|
220
|
+
|
|
221
|
+
This command copies templates from the installed vibetuner package to enable
|
|
222
|
+
Docker builds to run frontend builds in parallel with Python dependency
|
|
223
|
+
installation.
|
|
224
|
+
|
|
225
|
+
This is automatically run by copier during scaffold new/update, but can be
|
|
226
|
+
run manually if needed.
|
|
227
|
+
|
|
228
|
+
Examples:
|
|
229
|
+
|
|
230
|
+
# Copy templates to current directory
|
|
231
|
+
vibetuner scaffold copy-core-templates
|
|
232
|
+
|
|
233
|
+
# Copy templates to specific project
|
|
234
|
+
vibetuner scaffold copy-core-templates /path/to/project
|
|
235
|
+
"""
|
|
236
|
+
if destination is None:
|
|
237
|
+
destination = Path.cwd()
|
|
238
|
+
|
|
239
|
+
_copy_core_templates(destination)
|
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")
|
|
@@ -116,6 +118,8 @@ class CoreConfiguration(BaseSettings):
|
|
|
116
118
|
r2_secret_key: SecretStr | None = None
|
|
117
119
|
r2_default_region: str = "auto"
|
|
118
120
|
|
|
121
|
+
worker_concurrency: int = 16
|
|
122
|
+
|
|
119
123
|
@computed_field
|
|
120
124
|
@cached_property
|
|
121
125
|
def v_hash(self) -> str:
|
|
@@ -132,6 +136,16 @@ class CoreConfiguration(BaseSettings):
|
|
|
132
136
|
def mongo_dbname(self) -> str:
|
|
133
137
|
return self.project.project_slug
|
|
134
138
|
|
|
139
|
+
@cached_property
|
|
140
|
+
def redis_key_prefix(self) -> str:
|
|
141
|
+
"""Returns the Redis key prefix for namespacing all Redis keys by project and environment.
|
|
142
|
+
|
|
143
|
+
Format: "{project_slug}:{env}:" for dev, "{project_slug}:" for prod.
|
|
144
|
+
"""
|
|
145
|
+
if self.environment == "dev":
|
|
146
|
+
return f"{self.project.project_slug}:dev:"
|
|
147
|
+
return f"{self.project.project_slug}:"
|
|
148
|
+
|
|
135
149
|
model_config = SettingsConfigDict(
|
|
136
150
|
case_sensitive=False, extra="ignore", env_file=".env"
|
|
137
151
|
)
|
vibetuner/frontend/__init__.py
CHANGED
|
@@ -23,8 +23,8 @@ def register_router(router: APIRouter) -> None:
|
|
|
23
23
|
|
|
24
24
|
|
|
25
25
|
try:
|
|
26
|
-
import app.frontend.oauth as _app_oauth #
|
|
27
|
-
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
|
|
28
28
|
|
|
29
29
|
# Register OAuth routes after providers are registered
|
|
30
30
|
from .routes.auth import register_oauth_routes
|
|
@@ -39,6 +39,20 @@ except ImportError as e:
|
|
|
39
39
|
f"Failed to import app.frontend.oauth or app.frontend.routes: {e}. OAuth and custom routes will not be available."
|
|
40
40
|
)
|
|
41
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
|
+
)
|
|
55
|
+
|
|
42
56
|
|
|
43
57
|
dependencies: list[Any] = [
|
|
44
58
|
# Add any dependencies that should be available globally
|
|
@@ -103,5 +117,6 @@ def default_index(request: Request) -> HTMLResponse:
|
|
|
103
117
|
return render_template("index.html.jinja", request)
|
|
104
118
|
|
|
105
119
|
|
|
120
|
+
app.include_router(debug.auth_router)
|
|
106
121
|
app.include_router(debug.router)
|
|
107
122
|
app.include_router(health.router)
|
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/paths.py
CHANGED
|
@@ -33,24 +33,27 @@ def create_core_templates_symlink(target: Path) -> None:
|
|
|
33
33
|
logger.error(f"Cannot create symlink: {e}")
|
|
34
34
|
return
|
|
35
35
|
|
|
36
|
-
# Case 1: target
|
|
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
|
|
37
47
|
if not target.exists():
|
|
38
48
|
target.symlink_to(source, target_is_directory=True)
|
|
39
49
|
logger.info(f"Created symlink '{target}' → '{source}'")
|
|
40
50
|
return
|
|
41
51
|
|
|
42
|
-
# Case
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
# Case 3: is a symlink but points somewhere else → update it
|
|
50
|
-
if target.resolve() != source:
|
|
51
|
-
target.unlink()
|
|
52
|
-
target.symlink_to(source, target_is_directory=True)
|
|
53
|
-
logger.info(f"Updated symlink '{target}' → '{source}'")
|
|
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
|
+
)
|
|
54
57
|
|
|
55
58
|
|
|
56
59
|
# Package templates always available
|
vibetuner/services/blob.py
CHANGED
|
@@ -6,18 +6,13 @@ To extend blob functionality, create wrapper services in the parent services dir
|
|
|
6
6
|
|
|
7
7
|
import mimetypes
|
|
8
8
|
from pathlib import Path
|
|
9
|
-
from typing import Literal
|
|
10
9
|
|
|
11
10
|
import aioboto3
|
|
12
|
-
from aiobotocore.config import AioConfig
|
|
13
11
|
|
|
14
12
|
from vibetuner.config import settings
|
|
15
13
|
from vibetuner.models import BlobModel
|
|
16
14
|
from vibetuner.models.blob import BlobStatus
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
S3_SERVICE_NAME: Literal["s3"] = "s3"
|
|
20
|
-
DEFAULT_CONTENT_TYPE: str = "application/octet-stream"
|
|
15
|
+
from vibetuner.services.s3_storage import DEFAULT_CONTENT_TYPE, S3StorageService
|
|
21
16
|
|
|
22
17
|
|
|
23
18
|
class BlobService:
|
|
@@ -34,25 +29,22 @@ class BlobService:
|
|
|
34
29
|
raise ValueError(
|
|
35
30
|
"R2 bucket endpoint URL, access key, and secret key must be set in settings."
|
|
36
31
|
)
|
|
37
|
-
self.session = session or aioboto3.Session(
|
|
38
|
-
aws_access_key_id=settings.r2_access_key.get_secret_value(),
|
|
39
|
-
aws_secret_access_key=settings.r2_secret_key.get_secret_value(),
|
|
40
|
-
region_name=settings.r2_default_region,
|
|
41
|
-
)
|
|
42
|
-
self.endpoint_url = str(settings.r2_bucket_endpoint_url)
|
|
43
|
-
self.config = AioConfig(
|
|
44
|
-
request_checksum_calculation="when_required",
|
|
45
|
-
response_checksum_validation="when_required",
|
|
46
|
-
)
|
|
47
32
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
33
|
+
bucket = default_bucket or settings.r2_default_bucket_name
|
|
34
|
+
if bucket is None:
|
|
35
|
+
raise ValueError(
|
|
36
|
+
"Default bucket name must be provided either in settings or as an argument."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
self.storage = S3StorageService(
|
|
40
|
+
endpoint_url=str(settings.r2_bucket_endpoint_url),
|
|
41
|
+
access_key=settings.r2_access_key.get_secret_value(),
|
|
42
|
+
secret_key=settings.r2_secret_key.get_secret_value(),
|
|
43
|
+
region=settings.r2_default_region,
|
|
44
|
+
default_bucket=bucket,
|
|
45
|
+
session=session,
|
|
46
|
+
)
|
|
47
|
+
self.default_bucket = bucket
|
|
56
48
|
|
|
57
49
|
async def put_object(
|
|
58
50
|
self,
|
|
@@ -80,17 +72,12 @@ class BlobService:
|
|
|
80
72
|
raise ValueError("Blob ID must be set before uploading to R2.")
|
|
81
73
|
|
|
82
74
|
try:
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
Bucket=bucket,
|
|
90
|
-
Key=blob.full_path,
|
|
91
|
-
Body=body,
|
|
92
|
-
ContentType=content_type,
|
|
93
|
-
)
|
|
75
|
+
await self.storage.put_object(
|
|
76
|
+
key=blob.full_path,
|
|
77
|
+
body=body,
|
|
78
|
+
content_type=content_type,
|
|
79
|
+
bucket=bucket,
|
|
80
|
+
)
|
|
94
81
|
blob.status = BlobStatus.UPLOADED
|
|
95
82
|
except Exception:
|
|
96
83
|
blob.status = BlobStatus.ERROR
|
|
@@ -144,16 +131,10 @@ class BlobService:
|
|
|
144
131
|
if not blob:
|
|
145
132
|
raise ValueError(f"Blob not found: {key}")
|
|
146
133
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
) as s3_client:
|
|
152
|
-
response = await s3_client.get_object(
|
|
153
|
-
Bucket=blob.bucket,
|
|
154
|
-
Key=blob.full_path,
|
|
155
|
-
)
|
|
156
|
-
return await response["Body"].read()
|
|
134
|
+
return await self.storage.get_object(
|
|
135
|
+
key=blob.full_path,
|
|
136
|
+
bucket=blob.bucket,
|
|
137
|
+
)
|
|
157
138
|
|
|
158
139
|
async def delete_object(self, key: str) -> None:
|
|
159
140
|
"""Delete an object from the R2 bucket"""
|
|
@@ -172,4 +153,9 @@ class BlobService:
|
|
|
172
153
|
if not blob:
|
|
173
154
|
return False
|
|
174
155
|
|
|
156
|
+
if check_bucket:
|
|
157
|
+
return await self.storage.object_exists(
|
|
158
|
+
key=blob.full_path, bucket=blob.bucket
|
|
159
|
+
)
|
|
160
|
+
|
|
175
161
|
return True
|
vibetuner/services/email.py
CHANGED
|
@@ -6,7 +6,7 @@ To extend email functionality, create wrapper services in the parent services di
|
|
|
6
6
|
|
|
7
7
|
from typing import Literal
|
|
8
8
|
|
|
9
|
-
import
|
|
9
|
+
import aioboto3
|
|
10
10
|
|
|
11
11
|
from vibetuner.config import settings
|
|
12
12
|
|
|
@@ -17,11 +17,9 @@ SES_SERVICE_NAME: Literal["ses"] = "ses"
|
|
|
17
17
|
class SESEmailService:
|
|
18
18
|
def __init__(
|
|
19
19
|
self,
|
|
20
|
-
ses_client=None,
|
|
21
20
|
from_email: str | None = None,
|
|
22
21
|
) -> None:
|
|
23
|
-
self.
|
|
24
|
-
service_name=SES_SERVICE_NAME,
|
|
22
|
+
self.session = aioboto3.Session(
|
|
25
23
|
region_name=settings.project.aws_default_region,
|
|
26
24
|
aws_access_key_id=settings.aws_access_key_id.get_secret_value()
|
|
27
25
|
if settings.aws_access_key_id
|
|
@@ -36,15 +34,16 @@ class SESEmailService:
|
|
|
36
34
|
self, to_address: str, subject: str, html_body: str, text_body: str
|
|
37
35
|
):
|
|
38
36
|
"""Send email using Amazon SES"""
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
"
|
|
46
|
-
|
|
37
|
+
async with self.session.client(SES_SERVICE_NAME) as ses_client:
|
|
38
|
+
response = await ses_client.send_email(
|
|
39
|
+
Source=self.from_email,
|
|
40
|
+
Destination={"ToAddresses": [to_address]},
|
|
41
|
+
Message={
|
|
42
|
+
"Subject": {"Data": subject, "Charset": "UTF-8"},
|
|
43
|
+
"Body": {
|
|
44
|
+
"Html": {"Data": html_body, "Charset": "UTF-8"},
|
|
45
|
+
"Text": {"Data": text_body, "Charset": "UTF-8"},
|
|
46
|
+
},
|
|
47
47
|
},
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
return response
|
|
48
|
+
)
|
|
49
|
+
return response
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""ABOUTME: S3-compatible storage service for managing buckets and objects.
|
|
2
|
+
ABOUTME: Provides async operations for R2, MinIO, and other S3-compatible storage providers.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Literal
|
|
6
|
+
|
|
7
|
+
import aioboto3
|
|
8
|
+
from aiobotocore.config import AioConfig
|
|
9
|
+
from botocore.exceptions import ClientError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
S3_SERVICE_NAME: Literal["s3"] = "s3"
|
|
13
|
+
DEFAULT_CONTENT_TYPE: str = "application/octet-stream"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class S3StorageService:
|
|
17
|
+
"""Async S3-compatible storage service for bucket and object operations.
|
|
18
|
+
|
|
19
|
+
This service provides a clean interface to S3-compatible storage providers
|
|
20
|
+
(AWS S3, Cloudflare R2, MinIO, etc.) without any database dependencies.
|
|
21
|
+
|
|
22
|
+
All operations are async and use aioboto3 for efficient I/O.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(
|
|
26
|
+
self,
|
|
27
|
+
endpoint_url: str,
|
|
28
|
+
access_key: str,
|
|
29
|
+
secret_key: str,
|
|
30
|
+
region: str = "auto",
|
|
31
|
+
default_bucket: str | None = None,
|
|
32
|
+
session: aioboto3.Session | None = None,
|
|
33
|
+
) -> None:
|
|
34
|
+
"""Initialize S3 storage service with explicit configuration.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
endpoint_url: S3-compatible endpoint URL (e.g., "https://xxx.r2.cloudflarestorage.com")
|
|
38
|
+
access_key: Access key ID for authentication
|
|
39
|
+
secret_key: Secret access key for authentication
|
|
40
|
+
region: AWS region (default "auto" for R2/MinIO)
|
|
41
|
+
default_bucket: Optional default bucket for operations
|
|
42
|
+
session: Optional custom aioboto3 session
|
|
43
|
+
"""
|
|
44
|
+
self.endpoint_url = endpoint_url
|
|
45
|
+
self.default_bucket = default_bucket
|
|
46
|
+
self.session = session or aioboto3.Session(
|
|
47
|
+
aws_access_key_id=access_key,
|
|
48
|
+
aws_secret_access_key=secret_key,
|
|
49
|
+
region_name=region,
|
|
50
|
+
)
|
|
51
|
+
self.config = AioConfig(
|
|
52
|
+
request_checksum_calculation="when_required",
|
|
53
|
+
response_checksum_validation="when_required",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
def _get_bucket(self, bucket: str | None) -> str:
|
|
57
|
+
"""Get bucket name, using default if not specified.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
bucket: Optional bucket name
|
|
61
|
+
|
|
62
|
+
Returns:
|
|
63
|
+
Bucket name to use
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
ValueError: If no bucket specified and no default bucket set
|
|
67
|
+
"""
|
|
68
|
+
if bucket is None:
|
|
69
|
+
if self.default_bucket is None:
|
|
70
|
+
raise ValueError(
|
|
71
|
+
"No bucket specified and no default bucket configured. "
|
|
72
|
+
"Provide bucket parameter or set default_bucket during initialization."
|
|
73
|
+
)
|
|
74
|
+
return self.default_bucket
|
|
75
|
+
return bucket
|
|
76
|
+
|
|
77
|
+
# =========================================================================
|
|
78
|
+
# Object Operations
|
|
79
|
+
# =========================================================================
|
|
80
|
+
|
|
81
|
+
async def put_object(
|
|
82
|
+
self,
|
|
83
|
+
key: str,
|
|
84
|
+
body: bytes,
|
|
85
|
+
content_type: str = DEFAULT_CONTENT_TYPE,
|
|
86
|
+
bucket: str | None = None,
|
|
87
|
+
metadata: dict[str, str] | None = None,
|
|
88
|
+
) -> None:
|
|
89
|
+
"""Upload an object to S3-compatible storage.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
key: Object key (path) in the bucket
|
|
93
|
+
body: Raw bytes to upload
|
|
94
|
+
content_type: MIME type of the object
|
|
95
|
+
bucket: Bucket name (uses default_bucket if None)
|
|
96
|
+
metadata: Optional custom metadata dict
|
|
97
|
+
"""
|
|
98
|
+
bucket_name = self._get_bucket(bucket)
|
|
99
|
+
|
|
100
|
+
async with self.session.client(
|
|
101
|
+
service_name=S3_SERVICE_NAME,
|
|
102
|
+
endpoint_url=self.endpoint_url,
|
|
103
|
+
config=self.config,
|
|
104
|
+
) as s3_client:
|
|
105
|
+
put_params: dict[str, Any] = {
|
|
106
|
+
"Bucket": bucket_name,
|
|
107
|
+
"Key": key,
|
|
108
|
+
"Body": body,
|
|
109
|
+
"ContentType": content_type,
|
|
110
|
+
}
|
|
111
|
+
if metadata:
|
|
112
|
+
put_params["Metadata"] = metadata
|
|
113
|
+
|
|
114
|
+
await s3_client.put_object(**put_params)
|
|
115
|
+
|
|
116
|
+
async def get_object(self, key: str, bucket: str | None = None) -> bytes:
|
|
117
|
+
"""Retrieve an object from S3-compatible storage.
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
key: Object key (path) in the bucket
|
|
121
|
+
bucket: Bucket name (uses default_bucket if None)
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Raw bytes of the object
|
|
125
|
+
|
|
126
|
+
Raises:
|
|
127
|
+
ClientError: If object doesn't exist or other S3 error
|
|
128
|
+
"""
|
|
129
|
+
bucket_name = self._get_bucket(bucket)
|
|
130
|
+
|
|
131
|
+
async with self.session.client(
|
|
132
|
+
service_name=S3_SERVICE_NAME,
|
|
133
|
+
endpoint_url=self.endpoint_url,
|
|
134
|
+
config=self.config,
|
|
135
|
+
) as s3_client:
|
|
136
|
+
response = await s3_client.get_object(
|
|
137
|
+
Bucket=bucket_name,
|
|
138
|
+
Key=key,
|
|
139
|
+
)
|
|
140
|
+
return await response["Body"].read()
|
|
141
|
+
|
|
142
|
+
async def delete_object(self, key: str, bucket: str | None = None) -> None:
|
|
143
|
+
"""Delete an object from S3-compatible storage.
|
|
144
|
+
|
|
145
|
+
Args:
|
|
146
|
+
key: Object key (path) in the bucket
|
|
147
|
+
bucket: Bucket name (uses default_bucket if None)
|
|
148
|
+
"""
|
|
149
|
+
bucket_name = self._get_bucket(bucket)
|
|
150
|
+
|
|
151
|
+
async with self.session.client(
|
|
152
|
+
service_name=S3_SERVICE_NAME,
|
|
153
|
+
endpoint_url=self.endpoint_url,
|
|
154
|
+
config=self.config,
|
|
155
|
+
) as s3_client:
|
|
156
|
+
await s3_client.delete_object(
|
|
157
|
+
Bucket=bucket_name,
|
|
158
|
+
Key=key,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
async def object_exists(self, key: str, bucket: str | None = None) -> bool:
|
|
162
|
+
"""Check if an object exists in S3-compatible storage.
|
|
163
|
+
|
|
164
|
+
Args:
|
|
165
|
+
key: Object key (path) in the bucket
|
|
166
|
+
bucket: Bucket name (uses default_bucket if None)
|
|
167
|
+
|
|
168
|
+
Returns:
|
|
169
|
+
True if object exists, False otherwise
|
|
170
|
+
"""
|
|
171
|
+
bucket_name = self._get_bucket(bucket)
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
async with self.session.client(
|
|
175
|
+
service_name=S3_SERVICE_NAME,
|
|
176
|
+
endpoint_url=self.endpoint_url,
|
|
177
|
+
config=self.config,
|
|
178
|
+
) as s3_client:
|
|
179
|
+
await s3_client.head_object(
|
|
180
|
+
Bucket=bucket_name,
|
|
181
|
+
Key=key,
|
|
182
|
+
)
|
|
183
|
+
return True
|
|
184
|
+
except ClientError as e:
|
|
185
|
+
error_code = e.response.get("Error", {}).get("Code", "")
|
|
186
|
+
if error_code == "404":
|
|
187
|
+
return False
|
|
188
|
+
raise
|
|
189
|
+
|
|
190
|
+
async def list_objects(
|
|
191
|
+
self,
|
|
192
|
+
prefix: str | None = None,
|
|
193
|
+
bucket: str | None = None,
|
|
194
|
+
max_keys: int = 1000,
|
|
195
|
+
) -> list[dict[str, Any]]:
|
|
196
|
+
"""List objects in a bucket with optional prefix filter.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
prefix: Optional prefix to filter objects
|
|
200
|
+
bucket: Bucket name (uses default_bucket if None)
|
|
201
|
+
max_keys: Maximum number of keys to return (default 1000)
|
|
202
|
+
|
|
203
|
+
Returns:
|
|
204
|
+
List of object metadata dicts with keys: key, size, last_modified, etag
|
|
205
|
+
"""
|
|
206
|
+
bucket_name = self._get_bucket(bucket)
|
|
207
|
+
|
|
208
|
+
async with self.session.client(
|
|
209
|
+
service_name=S3_SERVICE_NAME,
|
|
210
|
+
endpoint_url=self.endpoint_url,
|
|
211
|
+
config=self.config,
|
|
212
|
+
) as s3_client:
|
|
213
|
+
list_params: dict[str, Any] = {
|
|
214
|
+
"Bucket": bucket_name,
|
|
215
|
+
"MaxKeys": max_keys,
|
|
216
|
+
}
|
|
217
|
+
if prefix:
|
|
218
|
+
list_params["Prefix"] = prefix
|
|
219
|
+
|
|
220
|
+
response = await s3_client.list_objects_v2(**list_params)
|
|
221
|
+
|
|
222
|
+
if "Contents" not in response:
|
|
223
|
+
return []
|
|
224
|
+
|
|
225
|
+
return [
|
|
226
|
+
{
|
|
227
|
+
"key": obj.get("Key", ""),
|
|
228
|
+
"size": obj.get("Size", 0),
|
|
229
|
+
"last_modified": obj.get("LastModified"),
|
|
230
|
+
"etag": obj.get("ETag", "").strip('"'),
|
|
231
|
+
}
|
|
232
|
+
for obj in response["Contents"]
|
|
233
|
+
]
|
|
234
|
+
|
|
235
|
+
async def get_object_metadata(
|
|
236
|
+
self, key: str, bucket: str | None = None
|
|
237
|
+
) -> dict[str, Any]:
|
|
238
|
+
"""Get metadata for an object without downloading it.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
key: Object key (path) in the bucket
|
|
242
|
+
bucket: Bucket name (uses default_bucket if None)
|
|
243
|
+
|
|
244
|
+
Returns:
|
|
245
|
+
Metadata dict with keys: content_type, size, last_modified, etag, metadata
|
|
246
|
+
"""
|
|
247
|
+
bucket_name = self._get_bucket(bucket)
|
|
248
|
+
|
|
249
|
+
async with self.session.client(
|
|
250
|
+
service_name=S3_SERVICE_NAME,
|
|
251
|
+
endpoint_url=self.endpoint_url,
|
|
252
|
+
config=self.config,
|
|
253
|
+
) as s3_client:
|
|
254
|
+
response = await s3_client.head_object(
|
|
255
|
+
Bucket=bucket_name,
|
|
256
|
+
Key=key,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
return {
|
|
260
|
+
"content_type": response.get("ContentType"),
|
|
261
|
+
"size": response.get("ContentLength"),
|
|
262
|
+
"last_modified": response.get("LastModified"),
|
|
263
|
+
"etag": response.get("ETag", "").strip('"'),
|
|
264
|
+
"metadata": response.get("Metadata", {}),
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
# =========================================================================
|
|
268
|
+
# Bucket Operations
|
|
269
|
+
# =========================================================================
|
|
270
|
+
|
|
271
|
+
async def list_buckets(self) -> list[dict[str, Any]]:
|
|
272
|
+
"""List all buckets accessible with current credentials.
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
List of bucket metadata dicts with keys: name, creation_date
|
|
276
|
+
"""
|
|
277
|
+
async with self.session.client(
|
|
278
|
+
service_name=S3_SERVICE_NAME,
|
|
279
|
+
endpoint_url=self.endpoint_url,
|
|
280
|
+
config=self.config,
|
|
281
|
+
) as s3_client:
|
|
282
|
+
response = await s3_client.list_buckets()
|
|
283
|
+
|
|
284
|
+
return [
|
|
285
|
+
{
|
|
286
|
+
"name": bucket.get("Name", ""),
|
|
287
|
+
"creation_date": bucket.get("CreationDate"),
|
|
288
|
+
}
|
|
289
|
+
for bucket in response.get("Buckets", [])
|
|
290
|
+
]
|
|
291
|
+
|
|
292
|
+
async def create_bucket(self, bucket: str, region: str | None = None) -> None:
|
|
293
|
+
"""Create a new bucket.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
bucket: Name of the bucket to create
|
|
297
|
+
region: Optional region (uses session default if None)
|
|
298
|
+
"""
|
|
299
|
+
async with self.session.client(
|
|
300
|
+
service_name=S3_SERVICE_NAME,
|
|
301
|
+
endpoint_url=self.endpoint_url,
|
|
302
|
+
config=self.config,
|
|
303
|
+
) as s3_client:
|
|
304
|
+
create_params: dict[str, Any] = {"Bucket": bucket}
|
|
305
|
+
|
|
306
|
+
# Only set CreateBucketConfiguration for non-us-east-1 regions
|
|
307
|
+
if region and region not in ("us-east-1", "auto"):
|
|
308
|
+
create_params["CreateBucketConfiguration"] = {
|
|
309
|
+
"LocationConstraint": region
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
await s3_client.create_bucket(**create_params)
|
|
313
|
+
|
|
314
|
+
async def delete_bucket(self, bucket: str, force: bool = False) -> None:
|
|
315
|
+
"""Delete a bucket.
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
bucket: Name of the bucket to delete
|
|
319
|
+
force: If True, delete all objects in bucket first
|
|
320
|
+
|
|
321
|
+
Note:
|
|
322
|
+
S3 buckets must be empty before deletion unless force=True
|
|
323
|
+
"""
|
|
324
|
+
if force:
|
|
325
|
+
# Delete all objects in the bucket first
|
|
326
|
+
objects = await self.list_objects(bucket=bucket)
|
|
327
|
+
async with self.session.client(
|
|
328
|
+
service_name=S3_SERVICE_NAME,
|
|
329
|
+
endpoint_url=self.endpoint_url,
|
|
330
|
+
config=self.config,
|
|
331
|
+
) as s3_client:
|
|
332
|
+
for obj in objects:
|
|
333
|
+
await s3_client.delete_object(
|
|
334
|
+
Bucket=bucket,
|
|
335
|
+
Key=obj["key"],
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
async with self.session.client(
|
|
339
|
+
service_name=S3_SERVICE_NAME,
|
|
340
|
+
endpoint_url=self.endpoint_url,
|
|
341
|
+
config=self.config,
|
|
342
|
+
) as s3_client:
|
|
343
|
+
await s3_client.delete_bucket(Bucket=bucket)
|
|
344
|
+
|
|
345
|
+
async def bucket_exists(self, bucket: str) -> bool:
|
|
346
|
+
"""Check if a bucket exists and is accessible.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
bucket: Name of the bucket to check
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
True if bucket exists and is accessible, False otherwise
|
|
353
|
+
"""
|
|
354
|
+
try:
|
|
355
|
+
async with self.session.client(
|
|
356
|
+
service_name=S3_SERVICE_NAME,
|
|
357
|
+
endpoint_url=self.endpoint_url,
|
|
358
|
+
config=self.config,
|
|
359
|
+
) as s3_client:
|
|
360
|
+
await s3_client.head_bucket(Bucket=bucket)
|
|
361
|
+
return True
|
|
362
|
+
except ClientError as e:
|
|
363
|
+
error_code = e.response.get("Error", {}).get("Code", "")
|
|
364
|
+
if error_code in ("404", "NoSuchBucket"):
|
|
365
|
+
return False
|
|
366
|
+
raise
|
|
367
|
+
|
|
368
|
+
async def get_bucket_location(self, bucket: str) -> str:
|
|
369
|
+
"""Get the region/location of a bucket.
|
|
370
|
+
|
|
371
|
+
Args:
|
|
372
|
+
bucket: Name of the bucket
|
|
373
|
+
|
|
374
|
+
Returns:
|
|
375
|
+
Region string (e.g., "us-east-1", "auto")
|
|
376
|
+
"""
|
|
377
|
+
async with self.session.client(
|
|
378
|
+
service_name=S3_SERVICE_NAME,
|
|
379
|
+
endpoint_url=self.endpoint_url,
|
|
380
|
+
config=self.config,
|
|
381
|
+
) as s3_client:
|
|
382
|
+
response = await s3_client.get_bucket_location(Bucket=bucket)
|
|
383
|
+
location = response.get("LocationConstraint")
|
|
384
|
+
# S3 returns None for us-east-1
|
|
385
|
+
return location if location else "us-east-1"
|
|
386
|
+
|
|
387
|
+
# =========================================================================
|
|
388
|
+
# Advanced Operations
|
|
389
|
+
# =========================================================================
|
|
390
|
+
|
|
391
|
+
async def copy_object(
|
|
392
|
+
self,
|
|
393
|
+
src_key: str,
|
|
394
|
+
dest_key: str,
|
|
395
|
+
src_bucket: str | None = None,
|
|
396
|
+
dest_bucket: str | None = None,
|
|
397
|
+
) -> None:
|
|
398
|
+
"""Copy an object from one location to another.
|
|
399
|
+
|
|
400
|
+
Args:
|
|
401
|
+
src_key: Source object key
|
|
402
|
+
dest_key: Destination object key
|
|
403
|
+
src_bucket: Source bucket (uses default_bucket if None)
|
|
404
|
+
dest_bucket: Destination bucket (uses default_bucket if None)
|
|
405
|
+
"""
|
|
406
|
+
src_bucket_name = self._get_bucket(src_bucket)
|
|
407
|
+
dest_bucket_name = self._get_bucket(dest_bucket)
|
|
408
|
+
|
|
409
|
+
async with self.session.client(
|
|
410
|
+
service_name=S3_SERVICE_NAME,
|
|
411
|
+
endpoint_url=self.endpoint_url,
|
|
412
|
+
config=self.config,
|
|
413
|
+
) as s3_client:
|
|
414
|
+
copy_source = f"{src_bucket_name}/{src_key}"
|
|
415
|
+
await s3_client.copy_object(
|
|
416
|
+
CopySource=copy_source,
|
|
417
|
+
Bucket=dest_bucket_name,
|
|
418
|
+
Key=dest_key,
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
async def generate_presigned_url(
|
|
422
|
+
self,
|
|
423
|
+
key: str,
|
|
424
|
+
bucket: str | None = None,
|
|
425
|
+
expiration: int = 3600,
|
|
426
|
+
method: str = "get_object",
|
|
427
|
+
) -> str:
|
|
428
|
+
"""Generate a presigned URL for temporary access to an object.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
key: Object key
|
|
432
|
+
bucket: Bucket name (uses default_bucket if None)
|
|
433
|
+
expiration: URL expiration time in seconds (default 3600 = 1 hour)
|
|
434
|
+
method: S3 method name ("get_object" or "put_object")
|
|
435
|
+
|
|
436
|
+
Returns:
|
|
437
|
+
Presigned URL string
|
|
438
|
+
"""
|
|
439
|
+
bucket_name = self._get_bucket(bucket)
|
|
440
|
+
|
|
441
|
+
async with self.session.client(
|
|
442
|
+
service_name=S3_SERVICE_NAME,
|
|
443
|
+
endpoint_url=self.endpoint_url,
|
|
444
|
+
config=self.config,
|
|
445
|
+
) as s3_client:
|
|
446
|
+
url = await s3_client.generate_presigned_url(
|
|
447
|
+
ClientMethod=method,
|
|
448
|
+
Params={
|
|
449
|
+
"Bucket": bucket_name,
|
|
450
|
+
"Key": key,
|
|
451
|
+
},
|
|
452
|
+
ExpiresIn=expiration,
|
|
453
|
+
)
|
|
454
|
+
return url
|
vibetuner/tasks/worker.py
CHANGED
|
@@ -6,10 +6,7 @@ from vibetuner.tasks.lifespan import lifespan
|
|
|
6
6
|
|
|
7
7
|
worker = Worker(
|
|
8
8
|
redis_url=str(settings.redis_url),
|
|
9
|
-
queue_name=(
|
|
10
|
-
settings.project.project_slug
|
|
11
|
-
if not settings.debug
|
|
12
|
-
else f"debug-{settings.project.project_slug}"
|
|
13
|
-
),
|
|
9
|
+
queue_name=settings.redis_key_prefix.rstrip(":"),
|
|
14
10
|
lifespan=lifespan,
|
|
11
|
+
concurrency=settings.worker_concurrency,
|
|
15
12
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: vibetuner
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.33.0
|
|
4
4
|
Summary: Core Python framework and blessed dependencies for production-ready FastAPI + MongoDB + HTMX projects
|
|
5
5
|
Keywords: fastapi,mongodb,htmx,web-framework,scaffolding,oauth,background-jobs
|
|
6
6
|
Author: All Tuner Labs, S.L.
|
|
@@ -20,20 +20,20 @@ Requires-Dist: aioboto3>=15.5.0
|
|
|
20
20
|
Requires-Dist: arel>=0.4.0
|
|
21
21
|
Requires-Dist: asyncer>=0.0.10
|
|
22
22
|
Requires-Dist: authlib>=1.6.5
|
|
23
|
-
Requires-Dist: beanie[zstd]>=2.0.
|
|
23
|
+
Requires-Dist: beanie[zstd]>=2.0.1
|
|
24
24
|
Requires-Dist: click>=8.3.1
|
|
25
|
-
Requires-Dist: copier>=9.
|
|
25
|
+
Requires-Dist: copier>=9.11.0,<9.11.1
|
|
26
26
|
Requires-Dist: email-validator>=2.3.0
|
|
27
|
-
Requires-Dist: fastapi[standard-no-fastapi-cloud-cli]>=0.
|
|
27
|
+
Requires-Dist: fastapi[standard-no-fastapi-cloud-cli]>=0.122.0
|
|
28
28
|
Requires-Dist: granian[pname]>=2.6.0
|
|
29
29
|
Requires-Dist: httpx[http2]>=0.28.1
|
|
30
30
|
Requires-Dist: itsdangerous>=2.2.0
|
|
31
31
|
Requires-Dist: loguru>=0.7.3
|
|
32
|
-
Requires-Dist: pydantic[email]>=2.12.
|
|
32
|
+
Requires-Dist: pydantic[email]>=2.12.5
|
|
33
33
|
Requires-Dist: pydantic-extra-types[pycountry]>=2.10.6
|
|
34
34
|
Requires-Dist: pydantic-settings>=2.12.0
|
|
35
35
|
Requires-Dist: pyyaml>=6.0.3
|
|
36
|
-
Requires-Dist: redis[hiredis]>=7.0
|
|
36
|
+
Requires-Dist: redis[hiredis]>=7.1.0
|
|
37
37
|
Requires-Dist: rich>=14.2.0
|
|
38
38
|
Requires-Dist: sse-starlette>=3.0.3
|
|
39
39
|
Requires-Dist: starlette-babel>=1.0.3
|
|
@@ -44,20 +44,22 @@ Requires-Dist: babel>=2.17.0 ; extra == 'dev'
|
|
|
44
44
|
Requires-Dist: cloudflare>=4.3.1 ; extra == 'dev'
|
|
45
45
|
Requires-Dist: djlint>=1.36.4 ; extra == 'dev'
|
|
46
46
|
Requires-Dist: dunamai>=1.25.0 ; extra == 'dev'
|
|
47
|
-
Requires-Dist: gh-bin>=2.83.
|
|
47
|
+
Requires-Dist: gh-bin>=2.83.1 ; extra == 'dev'
|
|
48
48
|
Requires-Dist: granian[pname,reload]>=2.6.0 ; extra == 'dev'
|
|
49
|
-
Requires-Dist: just-bin>=1.43.
|
|
50
|
-
Requires-Dist:
|
|
49
|
+
Requires-Dist: just-bin>=1.43.1 ; extra == 'dev'
|
|
50
|
+
Requires-Dist: prek>=0.2.19 ; extra == 'dev'
|
|
51
51
|
Requires-Dist: pysemver>=0.5.0 ; extra == 'dev'
|
|
52
|
-
Requires-Dist: ruff>=0.14.
|
|
53
|
-
Requires-Dist: rumdl>=0.0.
|
|
52
|
+
Requires-Dist: ruff>=0.14.6 ; extra == 'dev'
|
|
53
|
+
Requires-Dist: rumdl>=0.0.182 ; extra == 'dev'
|
|
54
54
|
Requires-Dist: semver>=3.0.4 ; extra == 'dev'
|
|
55
55
|
Requires-Dist: taplo>=0.9.3 ; extra == 'dev'
|
|
56
|
-
Requires-Dist: ty>=0.0.
|
|
56
|
+
Requires-Dist: ty>=0.0.1a28 ; extra == 'dev'
|
|
57
57
|
Requires-Dist: types-aioboto3[s3,ses]>=15.5.0 ; extra == 'dev'
|
|
58
58
|
Requires-Dist: types-authlib>=1.6.5.20251005 ; extra == 'dev'
|
|
59
59
|
Requires-Dist: types-pyyaml>=6.0.12.20250915 ; extra == 'dev'
|
|
60
60
|
Requires-Dist: uv-bump>=0.3.1 ; extra == 'dev'
|
|
61
|
+
Requires-Dist: pytest>=9.0.1 ; extra == 'test'
|
|
62
|
+
Requires-Dist: pytest-asyncio>=1.3.0 ; extra == 'test'
|
|
61
63
|
Requires-Python: >=3.11
|
|
62
64
|
Project-URL: Changelog, https://github.com/alltuner/vibetuner/blob/main/CHANGELOG.md
|
|
63
65
|
Project-URL: Documentation, https://vibetuner.alltuner.com/
|
|
@@ -65,6 +67,7 @@ Project-URL: Homepage, https://vibetuner.alltuner.com/
|
|
|
65
67
|
Project-URL: Issues, https://github.com/alltuner/vibetuner/issues
|
|
66
68
|
Project-URL: Repository, https://github.com/alltuner/vibetuner
|
|
67
69
|
Provides-Extra: dev
|
|
70
|
+
Provides-Extra: test
|
|
68
71
|
Description-Content-Type: text/markdown
|
|
69
72
|
|
|
70
73
|
# vibetuner
|
|
@@ -177,7 +180,7 @@ The `[dev]` extra includes all tools needed for development:
|
|
|
177
180
|
|
|
178
181
|
- **Ruff**: Fast linting and formatting
|
|
179
182
|
- **Babel**: i18n message extraction
|
|
180
|
-
- **pre-commit**: Git hooks
|
|
183
|
+
- **pre-commit**: Git hooks (prek is a fast pre-commit drop-in replacement)
|
|
181
184
|
- **Type stubs**: For aioboto3, authlib, PyYAML
|
|
182
185
|
- And more...
|
|
183
186
|
|
|
@@ -1,25 +1,25 @@
|
|
|
1
1
|
vibetuner/__init__.py,sha256=rFIVCmxkKTT_g477V8biCw0lgpudyuUabXhYxg189lY,90
|
|
2
2
|
vibetuner/__main__.py,sha256=Ye9oBAgXhcYQ4I4yZli3TIXF5lWQ9yY4tTPs4XnDDUY,29
|
|
3
|
-
vibetuner/cli/__init__.py,sha256=
|
|
4
|
-
vibetuner/cli/run.py,sha256=
|
|
5
|
-
vibetuner/cli/scaffold.py,sha256
|
|
6
|
-
vibetuner/config.py,sha256=
|
|
3
|
+
vibetuner/cli/__init__.py,sha256=lmuLn8ytkO0JwX3ILm6AFQmrzEMe5LeHmwV2oyyy0sI,3543
|
|
4
|
+
vibetuner/cli/run.py,sha256=i7lxJejvCND3_1_DFVETvpsq2gSKJ1PJOsPBMiuqEnw,5067
|
|
5
|
+
vibetuner/cli/scaffold.py,sha256=-hYmTjNclOA7CVhJq_t8L9hxWIHGj95ESYAiMBBpG_g,7278
|
|
6
|
+
vibetuner/config.py,sha256=hIybaQciXFZG6SvtHsTI62XwwVHH7C4CwYaPDXVO7rA,4605
|
|
7
7
|
vibetuner/context.py,sha256=h4f4FfkmLlOD6WiSLhx7-IjFvIA4zcrsAp6478l6npg,743
|
|
8
|
-
vibetuner/frontend/__init__.py,sha256=
|
|
8
|
+
vibetuner/frontend/__init__.py,sha256=COZuPRzTFUfxeyWkvmz0GxBBFFvBHtf2mMGmLw5fy2c,3965
|
|
9
9
|
vibetuner/frontend/deps.py,sha256=b3ocC_ryaK2Jp51SfcFqckrXiaL7V-chkFRqLjzgA_c,1296
|
|
10
10
|
vibetuner/frontend/email.py,sha256=k0d7FCZCge5VYOKp3fLsbx7EA5_SrtBkpMs57o4W7u0,1119
|
|
11
11
|
vibetuner/frontend/hotreload.py,sha256=Gl7FIKJaiCVVoyWQqdErBUOKDP1cGBFUpGzqHMiJd10,285
|
|
12
12
|
vibetuner/frontend/lifespan.py,sha256=ZduSyH6E2_IW9vFWH5jww2R22mRybSm_mNfbtDHnsS0,1074
|
|
13
|
-
vibetuner/frontend/middleware.py,sha256=
|
|
13
|
+
vibetuner/frontend/middleware.py,sha256=iOqcp-7WwRqHLYa1dg3xDzAXGrpqmYpqBmtQcdd9obY,4890
|
|
14
14
|
vibetuner/frontend/oauth.py,sha256=EzEwoOZ_8xn_CiqAWpNoEdhV2NPxZKKwF2bA6W6Bkj0,5884
|
|
15
15
|
vibetuner/frontend/routes/__init__.py,sha256=nHhiylHIUPZ2R-Bd7vXEGHLJBQ7fNuzPTJodjJR3lyc,428
|
|
16
16
|
vibetuner/frontend/routes/auth.py,sha256=vKE-Dm2yPXReaOLvcxfT4a6df1dKUoteZ4p46v8Elm4,4331
|
|
17
|
-
vibetuner/frontend/routes/debug.py,sha256=
|
|
17
|
+
vibetuner/frontend/routes/debug.py,sha256=hqiuRjcLOvL2DdhA83HUzp5svECKjyFB-LoNn7xDiFE,13269
|
|
18
18
|
vibetuner/frontend/routes/health.py,sha256=_XkMpdMNUemu7qzkGkqn5TBnZmGrArA3Xps5CWCcGlg,959
|
|
19
19
|
vibetuner/frontend/routes/language.py,sha256=wHNfdewqWfK-2JLXwglu0Q0b_e00HFGd0A2-PYT44LE,1240
|
|
20
20
|
vibetuner/frontend/routes/meta.py,sha256=pSyIxQsiB0QZSYwCQbS07KhkT5oHC5r9jvjUDIqZRGw,1409
|
|
21
21
|
vibetuner/frontend/routes/user.py,sha256=b8ow6IGnfsHosSwSmEIYZtuQJnW_tacnNjp_aMnqWxU,2666
|
|
22
|
-
vibetuner/frontend/templates.py,sha256=
|
|
22
|
+
vibetuner/frontend/templates.py,sha256=BLBBcF9e-EUDQXeOUzCHgQx1D0lCMkLfUXgNCwG6CtE,6607
|
|
23
23
|
vibetuner/logging.py,sha256=9eNofqVtKZCBDS33NbBI7Sv2875gM8MNStTSCjX2AXQ,2409
|
|
24
24
|
vibetuner/models/__init__.py,sha256=JvmQvzDIxaI7zlk-ROCWEbuzxXSUOqCshINUjgu-AfQ,325
|
|
25
25
|
vibetuner/models/blob.py,sha256=F30HFS4Z_Bji_PGPflWIv4dOwqKLsEWQHcjW1Oz_79M,2523
|
|
@@ -30,13 +30,14 @@ vibetuner/models/registry.py,sha256=O5YG7vOrWluqpH5N7m44v72wbscMhU_Pu3TJw_u0MTk,
|
|
|
30
30
|
vibetuner/models/types.py,sha256=Lj3ASEvx5eNgQMcVhNyKQHHolJqDxj2yH8S-M9oa4J8,402
|
|
31
31
|
vibetuner/models/user.py,sha256=ttcSH4mVREPhA6bCFUWXKfJ9_8_Iq3lEYXe3rDrslw4,2696
|
|
32
32
|
vibetuner/mongo.py,sha256=BejYrqHrupNfvNhEu6xUakCnRQy3YdiExk2r6hkafOs,1056
|
|
33
|
-
vibetuner/paths.py,sha256=
|
|
33
|
+
vibetuner/paths.py,sha256=hWWcjwVPNxac74rRuHgHD46CwmQKHJidEALo-euw72k,8360
|
|
34
34
|
vibetuner/services/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
35
|
-
vibetuner/services/blob.py,sha256
|
|
36
|
-
vibetuner/services/email.py,sha256=
|
|
35
|
+
vibetuner/services/blob.py,sha256=mYsMDA04E1fgXLaprWDip28RrxEX60T1G7UT1aMZDBY,5029
|
|
36
|
+
vibetuner/services/email.py,sha256=Y0yKgqki2an7fE6n4hQeROjVglzcqIGBgXICJbKZq0s,1669
|
|
37
|
+
vibetuner/services/s3_storage.py,sha256=purEKXKqb_5iroyDgEWCJ2DRMmG08Jy5tnsc2ZE1An4,15407
|
|
37
38
|
vibetuner/tasks/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
38
39
|
vibetuner/tasks/lifespan.py,sha256=BKOI-wbMLOqEbR4NgCKI5c9OP8W1RpxOswBL3XzPTWc,861
|
|
39
|
-
vibetuner/tasks/worker.py,sha256=
|
|
40
|
+
vibetuner/tasks/worker.py,sha256=t5HucY7ozxpmsbHnpQUyvcT8x-rHptMarDoyWpwGKaU,293
|
|
40
41
|
vibetuner/templates/email/magic_link.html.jinja,sha256=DzaCnBsYoau2JQh5enPAa2FMFFTyCwdyiM3vGhBQdtA,553
|
|
41
42
|
vibetuner/templates/email/magic_link.txt.jinja,sha256=dANak9ion1cpILt45V3GcI2qnL_gKFPj7PsZKYV0m5s,200
|
|
42
43
|
vibetuner/templates/frontend/base/favicons.html.jinja,sha256=A7s7YXuE82tRd7ZLJs1jGEGwBRiMPrqlWd507xL1iZg,70
|
|
@@ -65,7 +66,7 @@ vibetuner/templates/markdown/.placeholder,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5N
|
|
|
65
66
|
vibetuner/templates.py,sha256=xRoMb_oyAI5x4kxfpg56UcLKkT8e9HVn-o3KFAu9ISE,5094
|
|
66
67
|
vibetuner/time.py,sha256=3_DtveCCzI20ocTnAlTh2u7FByUXtINaUoQZO-_uZow,1188
|
|
67
68
|
vibetuner/versioning.py,sha256=c7Wg-SM-oJzQqG2RE0O8gZGHzHTgvwqa4yHn3Dk5-Sk,372
|
|
68
|
-
vibetuner-2.
|
|
69
|
-
vibetuner-2.
|
|
70
|
-
vibetuner-2.
|
|
71
|
-
vibetuner-2.
|
|
69
|
+
vibetuner-2.33.0.dist-info/WHEEL,sha256=3id4o64OvRm9dUknh3mMJNcfoTRK08ua5cU6DFyVy-4,79
|
|
70
|
+
vibetuner-2.33.0.dist-info/entry_points.txt,sha256=aKIj9YCCXizjYupx9PeWkUJePg3ncHke_LTS5rmCsfs,49
|
|
71
|
+
vibetuner-2.33.0.dist-info/METADATA,sha256=O-iSWeqqJXJuMZhqXLPIYe4sZuSQGLkSLYvPSrAwbcs,8227
|
|
72
|
+
vibetuner-2.33.0.dist-info/RECORD,,
|
|
File without changes
|