vibetuner 2.26.9__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 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/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
  )
@@ -117,5 +117,6 @@ def default_index(request: Request) -> HTMLResponse:
117
117
  return render_template("index.html.jinja", request)
118
118
 
119
119
 
120
+ app.include_router(debug.auth_router)
120
121
  app.include_router(debug.router)
121
122
  app.include_router(health.router)
@@ -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, prod: str | None = None):
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 prod=1 parameter
28
- if prod == "1":
29
- return True
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
- # Deny access
32
- raise HTTPException(status_code=404, detail="Not found")
33
+ return True
33
34
 
34
35
 
35
- router = APIRouter(prefix="/debug", dependencies=[Depends(check_debug_access)])
36
+ # Unprotected router for token-based debug access
37
+ auth_router = APIRouter()
36
38
 
37
39
 
38
- @router.get("/", response_class=HTMLResponse)
39
- def debug_index(request: Request):
40
- return render_template("debug/index.html.jinja", request)
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
- @router.get("/magic")
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
- @router.get("/no-magic")
59
- def remove_magic_cookie(response: Response):
60
- """Remove the magic access cookie."""
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
- # Common patterns for field names that reference other models
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
 
@@ -17,9 +17,38 @@ 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
50
+
51
+
23
52
  def timeago(dt):
24
53
  """Converts a datetime object to a human-readable string representing the time elapsed since the given datetime.
25
54
 
@@ -171,4 +200,22 @@ jinja_env.filters["format_datetime"] = format_datetime
171
200
  jinja_env.filters["format_duration"] = format_duration
172
201
  jinja_env.filters["duration"] = format_duration
173
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
174
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 does not existcreate symlink
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 2: exists but is not a symlink → error
43
- if not target.is_symlink():
44
- logger.error(f"Cannot create symlink: '{target}' exists and is not a symlink.")
45
- raise FileExistsError(
46
- f"Cannot create symlink: '{target}' exists and is not a symlink."
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
@@ -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
- if not default_bucket:
49
- if settings.r2_default_bucket_name is None:
50
- raise ValueError(
51
- "Default bucket name must be provided either in settings or as an argument."
52
- )
53
- self.default_bucket = settings.r2_default_bucket_name
54
- else:
55
- self.default_bucket = default_bucket
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
- async with self.session.client(
84
- service_name=S3_SERVICE_NAME,
85
- endpoint_url=self.endpoint_url,
86
- config=self.config,
87
- ) as s3_client:
88
- await s3_client.put_object(
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
- async with self.session.client(
148
- service_name=S3_SERVICE_NAME,
149
- endpoint_url=self.endpoint_url,
150
- config=self.config,
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
@@ -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 boto3
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.ses_client = ses_client or boto3.client(
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
- response = self.ses_client.send_email(
40
- Source=self.from_email,
41
- Destination={"ToAddresses": [to_address]},
42
- Message={
43
- "Subject": {"Data": subject, "Charset": "UTF-8"},
44
- "Body": {
45
- "Html": {"Data": html_body, "Charset": "UTF-8"},
46
- "Text": {"Data": text_body, "Charset": "UTF-8"},
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,6 @@ 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,
15
11
  )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: vibetuner
3
- Version: 2.26.9
3
+ Version: 2.30.1
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,11 +20,11 @@ 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.0
23
+ Requires-Dist: beanie[zstd]>=2.0.1
24
24
  Requires-Dist: click>=8.3.1
25
- Requires-Dist: copier>=9.10.3,<9.10.4
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.121.2
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
@@ -33,7 +33,7 @@ Requires-Dist: pydantic[email]>=2.12.4
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.1
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.0 ; extra == 'dev'
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.0 ; extra == 'dev'
50
- Requires-Dist: pre-commit>=4.4.0 ; extra == 'dev'
49
+ Requires-Dist: just-bin>=1.43.1 ; extra == 'dev'
50
+ Requires-Dist: prek>=0.2.18 ; extra == 'dev'
51
51
  Requires-Dist: pysemver>=0.5.0 ; extra == 'dev'
52
- Requires-Dist: ruff>=0.14.5 ; extra == 'dev'
53
- Requires-Dist: rumdl>=0.0.177 ; extra == 'dev'
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.1a26 ; extra == 'dev'
56
+ Requires-Dist: ty>=0.0.1a27 ; 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,11 +1,11 @@
1
1
  vibetuner/__init__.py,sha256=rFIVCmxkKTT_g477V8biCw0lgpudyuUabXhYxg189lY,90
2
2
  vibetuner/__main__.py,sha256=Ye9oBAgXhcYQ4I4yZli3TIXF5lWQ9yY4tTPs4XnDDUY,29
3
- vibetuner/cli/__init__.py,sha256=9FFy0MTOhWVS-Z310auNTcpbG1xJtku0TS35Qxoyt-Q,3995
3
+ vibetuner/cli/__init__.py,sha256=lmuLn8ytkO0JwX3ILm6AFQmrzEMe5LeHmwV2oyyy0sI,3543
4
4
  vibetuner/cli/run.py,sha256=TILyvy-bZTKWcAK-K2SNYqqD-G3ypCay-ghadGztqRQ,5021
5
- vibetuner/cli/scaffold.py,sha256=qADWxx1gYECQ8N6dgvJmlucT6mZ29rxWu3VZhbWmhC0,5710
6
- vibetuner/config.py,sha256=oG5naa6pu8wM4hzra0xaRQCPnQWbid9hGTJOHfsmHeI,4082
5
+ vibetuner/cli/scaffold.py,sha256=5MipgasDKsK6WDa_vpfY4my1kyismkaskvXE277NE-s,6450
6
+ vibetuner/config.py,sha256=UfEyAlow6lE_npr53dgESb-KZQuohP18vlTncOuWJC4,4571
7
7
  vibetuner/context.py,sha256=h4f4FfkmLlOD6WiSLhx7-IjFvIA4zcrsAp6478l6npg,743
8
- vibetuner/frontend/__init__.py,sha256=DIAnvQHCFNBc5VtBXYE6IdAnIqCYTD_EyFAbmui7vW8,3927
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
@@ -14,12 +14,12 @@ vibetuner/frontend/middleware.py,sha256=iOqcp-7WwRqHLYa1dg3xDzAXGrpqmYpqBmtQcdd9
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=Mzbyx3gv_mCxg0sdjY4p7LXgH1wI3BoEyn1sRZuGY90,12743
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=xHpLMyAEjKWHgbloE40n6Fe3gjTuG5msWeqqYyJO3uU,5308
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=IEH_GCJTThUPU0HWxr86j61IcwYgwXBamoWNmaJsxlo,8252
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=-lEGIWe30yR2IuTfy5bB9Sg_PX0HoYC_WMTQ3VN28gU,5660
36
- vibetuner/services/email.py,sha256=IavJZS5MI40LlF_cjBpPPRx_S2r1JD4GcFg3-dWkzPA,1626
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=s91Pbmz--cV4ygzDuXa-kGLZFe28HSvaXfxhIWJyIhE,340
40
+ vibetuner/tasks/worker.py,sha256=ZCfBpaqpyDG5OD3wtSf9YdIWie6CaQ49KwLNn_iTNCc,248
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.26.9.dist-info/WHEEL,sha256=w4ZtLaDgMAZW2MMZZwtH8zENekoQYBCeullI-zsXJQk,78
69
- vibetuner-2.26.9.dist-info/entry_points.txt,sha256=aKIj9YCCXizjYupx9PeWkUJePg3ncHke_LTS5rmCsfs,49
70
- vibetuner-2.26.9.dist-info/METADATA,sha256=WTPJkk2ZcYPzpzcWRdWDAnEgXcdjOBD3EgWo_ilp0sQ,8061
71
- vibetuner-2.26.9.dist-info/RECORD,,
69
+ vibetuner-2.30.1.dist-info/WHEEL,sha256=YUH1mBqsx8Dh2cQG2rlcuRYUhJddG9iClegy4IgnHik,79
70
+ vibetuner-2.30.1.dist-info/entry_points.txt,sha256=aKIj9YCCXizjYupx9PeWkUJePg3ncHke_LTS5rmCsfs,49
71
+ vibetuner-2.30.1.dist-info/METADATA,sha256=O25ikO_Zt59f7VbnTMTwQV4ADEFZb0cj7ze2zZzwiE8,8227
72
+ vibetuner-2.30.1.dist-info/RECORD,,
@@ -1,4 +1,4 @@
1
1
  Wheel-Version: 1.0
2
- Generator: uv 0.9.9
2
+ Generator: uv 0.9.11
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any