vibetuner 2.26.9__py3-none-any.whl → 2.44.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,14 +4,13 @@ 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
12
10
  from rich.console import Console
13
11
  from rich.table import Table
14
12
 
13
+ from vibetuner.cli.db import db_app
15
14
  from vibetuner.cli.run import run_app
16
15
  from vibetuner.cli.scaffold import scaffold_app
17
16
  from vibetuner.logging import LogLevel, logger, setup_logging
@@ -111,21 +110,7 @@ def version(
111
110
  console.print(table)
112
111
 
113
112
 
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
-
113
+ app.add_typer(db_app, name="db")
129
114
  app.add_typer(run_app, name="run")
130
115
  app.add_typer(scaffold_app, name="scaffold")
131
116
 
vibetuner/cli/db.py ADDED
@@ -0,0 +1,40 @@
1
+ # ABOUTME: Database management CLI commands for SQLModel.
2
+ # ABOUTME: Provides schema creation and other database utilities.
3
+
4
+ import asyncer
5
+ import typer
6
+
7
+
8
+ db_app = typer.Typer(help="Database management commands", no_args_is_help=True)
9
+
10
+
11
+ @db_app.command("create-schema")
12
+ def create_schema_cmd() -> None:
13
+ """
14
+ Create database tables from SQLModel metadata.
15
+
16
+ This command creates all tables defined in your SQLModel models.
17
+ It's idempotent - existing tables are not modified.
18
+
19
+ Run this during initial setup or after adding new models.
20
+ """
21
+ from importlib import import_module
22
+
23
+ from vibetuner.logging import logger
24
+
25
+ # Import app.models to register SQLModel tables before schema creation
26
+ try:
27
+ import_module("app.models")
28
+ except ModuleNotFoundError:
29
+ logger.warning("app.models not found. Only core models will be created.")
30
+ except ImportError as e:
31
+ logger.warning(f"Failed to import app.models: {e}")
32
+
33
+ async def _create() -> None:
34
+ from vibetuner.sqlmodel import create_schema
35
+
36
+ await create_schema()
37
+
38
+ typer.echo("Creating database schema...")
39
+ asyncer.runnify(_create)()
40
+ typer.echo("Database schema created successfully.")
vibetuner/cli/run.py CHANGED
@@ -1,11 +1,15 @@
1
1
  # ABOUTME: Run commands for starting the application in different modes
2
2
  # ABOUTME: Supports dev/prod modes for frontend and worker services
3
+ import hashlib
3
4
  import os
4
- from typing import Annotated
5
+ from pathlib import Path
6
+ from typing import Annotated, Literal
5
7
 
6
8
  import typer
7
9
  from rich.console import Console
8
10
 
11
+ from vibetuner.logging import logger
12
+
9
13
 
10
14
  console = Console()
11
15
 
@@ -13,88 +17,148 @@ run_app = typer.Typer(
13
17
  help="Run the application in different modes", no_args_is_help=True
14
18
  )
15
19
 
20
+ DEFAULT_FRONTEND_PORT = 8000
21
+ DEFAULT_WORKER_PORT = 11111
16
22
 
17
- @run_app.command(name="dev")
18
- def dev(
19
- service: Annotated[
20
- str, typer.Argument(help="Service to run: 'frontend' or 'worker'")
21
- ] = "frontend",
22
- port: int = typer.Option(
23
- None, help="Port to run on (8000 for frontend, 11111 for worker)"
24
- ),
25
- host: str = typer.Option("0.0.0.0", help="Host to bind to (frontend only)"), # noqa: S104
26
- workers_count: int = typer.Option(
27
- 1, "--workers", help="Number of worker processes"
28
- ),
29
- ) -> None:
30
- """Run in development mode with hot reload (frontend or worker)."""
31
- os.environ["DEBUG"] = "1"
32
23
 
33
- if service == "worker":
34
- # Worker mode
35
- from streaq.cli import main as streaq_main
24
+ def _compute_auto_port() -> int:
25
+ """Compute deterministic port from current directory path."""
26
+ cwd = os.getcwd()
27
+ hash_bytes = hashlib.sha256(cwd.encode()).digest()
28
+ hash_int = int.from_bytes(hash_bytes[:4], "big")
29
+ return 8001 + (hash_int % 999)
36
30
 
37
- worker_port = port if port else 11111
38
- console.print(
39
- f"[green]Starting worker in dev mode on port {worker_port}[/green]"
40
- )
41
- console.print("[dim]Hot reload enabled[/dim]")
42
31
 
43
- if workers_count > 1:
44
- console.print(
45
- "[yellow]Warning: Multiple workers not supported in dev mode, using 1[/yellow]"
46
- )
47
-
48
- # Call streaq programmatically
49
- streaq_main(
50
- worker_path="vibetuner.tasks.worker.worker",
51
- workers=1,
52
- reload=True,
53
- verbose=True,
54
- web=True,
55
- host="0.0.0.0", # noqa: S104
56
- port=worker_port,
32
+ def _run_worker(mode: Literal["dev", "prod"], port: int, workers: int) -> None:
33
+ """Start the background worker process."""
34
+ from streaq.cli import main as streaq_main
35
+
36
+ from vibetuner.config import settings
37
+
38
+ if not settings.workers_available:
39
+ logger.warning("Redis URL not configured. Workers will not be started.")
40
+ console.print(
41
+ "[red]Error: Redis URL not configured. Workers will not be started.[/red]"
57
42
  )
58
- elif service == "frontend":
59
- # Frontend mode
60
- from pathlib import Path
43
+ raise typer.Exit(code=0)
61
44
 
62
- from granian import Granian
63
- from granian.constants import Interfaces
45
+ is_dev = mode == "dev"
64
46
 
65
- frontend_port = port if port else 8000
47
+ if is_dev and workers > 1:
66
48
  console.print(
67
- f"[green]Starting frontend in dev mode on {host}:{frontend_port}[/green]"
49
+ "[yellow]Warning: Multiple workers not supported in dev mode, using 1[/yellow]"
68
50
  )
51
+ workers = 1
52
+
53
+ console.print(f"[green]Starting worker in {mode} mode on port {port}[/green]")
54
+ if is_dev:
55
+ console.print("[dim]Hot reload enabled[/dim]")
56
+ else:
57
+ console.print(f"[dim]Workers: {workers}[/dim]")
58
+
59
+ streaq_main(
60
+ worker_path="vibetuner.tasks.worker.worker",
61
+ workers=workers,
62
+ reload=is_dev,
63
+ verbose=True if is_dev else settings.debug,
64
+ web=True,
65
+ host="0.0.0.0", # noqa: S104
66
+ port=port,
67
+ )
68
+
69
+
70
+ def _run_frontend(
71
+ mode: Literal["dev", "prod"], host: str, port: int, workers: int
72
+ ) -> None:
73
+ """Start the frontend server."""
74
+ from granian import Granian
75
+ from granian.constants import Interfaces
76
+
77
+ is_dev = mode == "dev"
78
+
79
+ console.print(f"[green]Starting frontend in {mode} mode on {host}:{port}[/green]")
80
+ console.print(f"[cyan]website reachable at http://localhost:{port}[/cyan]")
81
+ console.print(
82
+ f"[cyan]website reachable at https://{port}.localdev.alltuner.com:12000/[/cyan]"
83
+ )
84
+ if is_dev:
69
85
  console.print("[dim]Watching for changes in src/ and templates/[/dim]")
86
+ else:
87
+ console.print(f"[dim]Workers: {workers}[/dim]")
70
88
 
71
- # Define paths to watch for changes
72
- reload_paths = [
89
+ reload_paths = (
90
+ [
73
91
  Path("src/app"),
74
92
  Path("templates/frontend"),
75
93
  Path("templates/email"),
76
94
  Path("templates/markdown"),
77
95
  ]
78
-
79
- server = Granian(
80
- target="vibetuner.frontend:app",
81
- address=host,
82
- port=frontend_port,
83
- interface=Interfaces.ASGI,
84
- workers=workers_count,
85
- reload=True,
86
- reload_paths=reload_paths,
87
- log_level="info",
88
- log_access=True,
89
- )
90
-
91
- server.serve()
96
+ if is_dev
97
+ else None
98
+ )
99
+
100
+ server = Granian(
101
+ target="vibetuner.frontend.proxy:app",
102
+ address=host,
103
+ port=port,
104
+ interface=Interfaces.ASGI,
105
+ workers=workers,
106
+ reload=is_dev,
107
+ reload_paths=reload_paths,
108
+ log_level="info",
109
+ log_access=True,
110
+ )
111
+
112
+ server.serve()
113
+
114
+
115
+ def _run_service(
116
+ mode: Literal["dev", "prod"],
117
+ service: str,
118
+ host: str,
119
+ port: int | None,
120
+ workers: int,
121
+ ) -> None:
122
+ """Dispatch to the appropriate service runner."""
123
+ if service == "worker":
124
+ _run_worker(mode, port or DEFAULT_WORKER_PORT, workers)
125
+ elif service == "frontend":
126
+ _run_frontend(mode, host, port or DEFAULT_FRONTEND_PORT, workers)
92
127
  else:
93
128
  console.print(f"[red]Error: Unknown service '{service}'[/red]")
94
129
  console.print("[yellow]Valid services: 'frontend' or 'worker'[/yellow]")
95
130
  raise typer.Exit(code=1)
96
131
 
97
132
 
133
+ @run_app.command(name="dev")
134
+ def dev(
135
+ service: Annotated[
136
+ str, typer.Argument(help="Service to run: 'frontend' or 'worker'")
137
+ ] = "frontend",
138
+ port: int | None = typer.Option(
139
+ None, help="Port to run on (8000 for frontend, 11111 for worker)"
140
+ ),
141
+ auto_port: bool = typer.Option(
142
+ False,
143
+ "--auto-port",
144
+ help="Use deterministic port based on project path (8001-8999)",
145
+ ),
146
+ host: str = typer.Option("0.0.0.0", help="Host to bind to (frontend only)"), # noqa: S104
147
+ workers_count: int = typer.Option(
148
+ 1, "--workers", help="Number of worker processes"
149
+ ),
150
+ ) -> None:
151
+ """Run in development mode with hot reload (frontend or worker)."""
152
+ if port is not None and auto_port:
153
+ console.print("[red]Error: --port and --auto-port are mutually exclusive[/red]")
154
+ raise typer.Exit(code=1)
155
+
156
+ if auto_port:
157
+ port = _compute_auto_port()
158
+
159
+ _run_service("dev", service, host, port, workers_count)
160
+
161
+
98
162
  @run_app.command(name="prod")
99
163
  def prod(
100
164
  service: Annotated[
@@ -109,52 +173,4 @@ def prod(
109
173
  ),
110
174
  ) -> None:
111
175
  """Run in production mode (frontend or worker)."""
112
- os.environ["ENVIRONMENT"] = "production"
113
-
114
- if service == "worker":
115
- # Worker mode
116
- from streaq.cli import main as streaq_main
117
-
118
- worker_port = port if port else 11111
119
- console.print(
120
- f"[green]Starting worker in prod mode on port {worker_port}[/green]"
121
- )
122
- console.print(f"[dim]Workers: {workers_count}[/dim]")
123
-
124
- # Call streaq programmatically
125
- streaq_main(
126
- worker_path="vibetuner.tasks.worker.worker",
127
- workers=workers_count,
128
- reload=False,
129
- verbose=False,
130
- web=True,
131
- host="0.0.0.0", # noqa: S104
132
- port=worker_port,
133
- )
134
- elif service == "frontend":
135
- # Frontend mode
136
- from granian import Granian
137
- from granian.constants import Interfaces
138
-
139
- frontend_port = port if port else 8000
140
- console.print(
141
- f"[green]Starting frontend in prod mode on {host}:{frontend_port}[/green]"
142
- )
143
- console.print(f"[dim]Workers: {workers_count}[/dim]")
144
-
145
- server = Granian(
146
- target="vibetuner.frontend:app",
147
- address=host,
148
- port=frontend_port,
149
- interface=Interfaces.ASGI,
150
- workers=workers_count,
151
- reload=False,
152
- log_level="info",
153
- log_access=True,
154
- )
155
-
156
- server.serve()
157
- else:
158
- console.print(f"[red]Error: Unknown service '{service}'[/red]")
159
- console.print("[yellow]Valid services: 'frontend' or 'worker'[/yellow]")
160
- raise typer.Exit(code=1)
176
+ _run_service("prod", service, host, port, workers_count)
vibetuner/cli/scaffold.py CHANGED
@@ -10,6 +10,7 @@ from rich.console import Console
10
10
 
11
11
  console = Console()
12
12
 
13
+
13
14
  scaffold_app = typer.Typer(
14
15
  help="Create new projects from the vibetuner template", no_args_is_help=True
15
16
  )
@@ -185,3 +186,9 @@ def update(
185
186
  except Exception as e:
186
187
  console.print(f"[red]Error updating project: {e}[/red]")
187
188
  raise typer.Exit(code=1) from None
189
+
190
+
191
+ @scaffold_app.command(name="copy-core-templates", hidden=True)
192
+ def copy_core_templates() -> None:
193
+ """Deprecated: This command is a no-op kept for backwards compatibility."""
194
+ console.print("[dim]This command is deprecated and does nothing.[/dim]")
vibetuner/config.py CHANGED
@@ -2,16 +2,21 @@ 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 (
9
9
  UUID4,
10
+ AnyUrl,
10
11
  Field,
11
12
  HttpUrl,
13
+ MariaDBDsn,
12
14
  MongoDsn,
15
+ MySQLDsn,
16
+ PostgresDsn,
13
17
  RedisDsn,
14
18
  SecretStr,
19
+ UrlConstraints,
15
20
  computed_field,
16
21
  )
17
22
  from pydantic_extra_types.language_code import LanguageAlpha2
@@ -23,6 +28,24 @@ from .paths import config_vars as config_vars_path
23
28
  from .versioning import version
24
29
 
25
30
 
31
+ class SQLiteDsn(AnyUrl):
32
+ """A type that will accept any SQLite DSN.
33
+
34
+ * User info not required
35
+ * TLD not required
36
+ * Host not required (file-based database)
37
+ """
38
+
39
+ _constraints = UrlConstraints(
40
+ allowed_schemes=[
41
+ "sqlite",
42
+ "sqlite+aiosqlite",
43
+ "sqlite+pysqlite",
44
+ ],
45
+ host_required=False,
46
+ )
47
+
48
+
26
49
  current_year: int = datetime.now().year
27
50
 
28
51
 
@@ -100,12 +123,15 @@ class CoreConfiguration(BaseSettings):
100
123
  project: ProjectConfiguration = ProjectConfiguration.from_project_config()
101
124
 
102
125
  debug: bool = False
126
+ environment: Literal["dev", "prod"] = "dev"
103
127
  version: str = version
104
128
  session_key: SecretStr = SecretStr("ct-!secret-must-change-me")
129
+ debug_access_token: str | None = None
105
130
 
106
131
  # Database and Cache URLs
107
- mongodb_url: MongoDsn = MongoDsn("mongodb://localhost:27017")
108
- redis_url: RedisDsn = RedisDsn("redis://localhost:6379")
132
+ mongodb_url: MongoDsn | None = None
133
+ redis_url: RedisDsn | None = None
134
+ database_url: PostgresDsn | MariaDBDsn | MySQLDsn | SQLiteDsn | None = None
109
135
 
110
136
  aws_access_key_id: SecretStr | None = None
111
137
  aws_secret_access_key: SecretStr | None = None
@@ -116,6 +142,18 @@ class CoreConfiguration(BaseSettings):
116
142
  r2_secret_key: SecretStr | None = None
117
143
  r2_default_region: str = "auto"
118
144
 
145
+ worker_concurrency: int = 16
146
+
147
+ # Proxy configuration for X-Forwarded-For/Proto headers
148
+ # Comma-separated list of trusted proxy IPs/CIDRs (e.g., "127.0.0.1,192.168.1.0/24")
149
+ # SECURITY: Only IPs in this list can set forwarded headers. Use "*" to trust all (NOT recommended for production)
150
+ trusted_proxy_hosts: str = "127.0.0.1"
151
+
152
+ @cached_property
153
+ def trusted_proxy_hosts_list(self) -> list[str]:
154
+ """Parse trusted proxy hosts into a list for Granian's proxy header wrapper."""
155
+ return [h.strip() for h in self.trusted_proxy_hosts.split(",") if h.strip()]
156
+
119
157
  @computed_field
120
158
  @cached_property
121
159
  def v_hash(self) -> str:
@@ -128,10 +166,24 @@ class CoreConfiguration(BaseSettings):
128
166
 
129
167
  return url_safe_hash
130
168
 
169
+ @property
170
+ def workers_available(self) -> bool:
171
+ return self.redis_url is not None
172
+
131
173
  @cached_property
132
174
  def mongo_dbname(self) -> str:
133
175
  return self.project.project_slug
134
176
 
177
+ @cached_property
178
+ def redis_key_prefix(self) -> str:
179
+ """Returns the Redis key prefix for namespacing all Redis keys by project and environment.
180
+
181
+ Format: "{project_slug}:{env}:" for dev, "{project_slug}:" for prod.
182
+ """
183
+ if self.environment == "dev":
184
+ return f"{self.project.project_slug}:dev:"
185
+ return f"{self.project.project_slug}:"
186
+
135
187
  model_config = SettingsConfigDict(
136
188
  case_sensitive=False, extra="ignore", env_file=".env"
137
189
  )
@@ -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)
@@ -5,7 +5,8 @@ from fastapi import FastAPI
5
5
 
6
6
  from vibetuner.context import ctx
7
7
  from vibetuner.logging import logger
8
- from vibetuner.mongo import init_models
8
+ from vibetuner.mongo import init_mongodb, teardown_mongodb
9
+ from vibetuner.sqlmodel import init_sqlmodel, teardown_sqlmodel
9
10
 
10
11
  from .hotreload import hotreload
11
12
 
@@ -16,7 +17,8 @@ async def base_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
16
17
  if ctx.DEBUG:
17
18
  await hotreload.startup()
18
19
 
19
- await init_models()
20
+ await init_mongodb()
21
+ await init_sqlmodel()
20
22
 
21
23
  yield
22
24
 
@@ -25,6 +27,9 @@ async def base_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
25
27
  await hotreload.shutdown()
26
28
  logger.info("Vibetuner frontend stopped")
27
29
 
30
+ await teardown_sqlmodel()
31
+ await teardown_mongodb()
32
+
28
33
 
29
34
  try:
30
35
  from app.frontend.lifespan import lifespan # ty: ignore
@@ -6,7 +6,6 @@ from starlette.middleware.authentication import AuthenticationMiddleware
6
6
  from starlette.middleware.base import BaseHTTPMiddleware
7
7
  from starlette.middleware.sessions import SessionMiddleware
8
8
  from starlette.middleware.trustedhost import TrustedHostMiddleware
9
- from starlette.types import ASGIApp, Receive, Scope, Send
10
9
  from starlette_babel import (
11
10
  LocaleFromCookie,
12
11
  LocaleFromQuery,
@@ -84,29 +83,6 @@ class AdjustLangCookieMiddleware(BaseHTTPMiddleware):
84
83
  return response
85
84
 
86
85
 
87
- class ForwardedProtocolMiddleware:
88
- def __init__(self, app: ASGIApp):
89
- self.app = app
90
-
91
- # Based on https://github.com/encode/uvicorn/blob/master/uvicorn/middleware/proxy_headers.py
92
- async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
93
- if scope["type"] == "lifespan":
94
- return await self.app(scope, receive, send)
95
-
96
- headers = dict(scope["headers"])
97
-
98
- if b"x-forwarded-proto" in headers:
99
- x_forwarded_proto = headers[b"x-forwarded-proto"].decode("latin1").strip()
100
-
101
- if x_forwarded_proto in {"http", "https", "ws", "wss"}:
102
- if scope["type"] == "websocket":
103
- scope["scheme"] = x_forwarded_proto.replace("http", "ws")
104
- else:
105
- scope["scheme"] = x_forwarded_proto
106
-
107
- return await self.app(scope, receive, send)
108
-
109
-
110
86
  class AuthBackend(AuthenticationBackend):
111
87
  async def authenticate(
112
88
  self,
@@ -128,7 +104,6 @@ class AuthBackend(AuthenticationBackend):
128
104
 
129
105
  middlewares: list[Middleware] = [
130
106
  Middleware(TrustedHostMiddleware),
131
- Middleware(ForwardedProtocolMiddleware),
132
107
  Middleware(HtmxMiddleware),
133
108
  Middleware(SessionMiddleware, secret_key=settings.session_key.get_secret_value()),
134
109
  Middleware(
@@ -0,0 +1,14 @@
1
+ # ABOUTME: ASGI app wrapper that processes X-Forwarded-For and X-Forwarded-Proto headers
2
+ # ABOUTME: Uses Granian's proxy header support to extract real client IP and protocol
3
+ from granian.utils.proxies import wrap_asgi_with_proxy_headers
4
+
5
+ from vibetuner.config import settings
6
+ from vibetuner.frontend import app as _app
7
+
8
+
9
+ # Wrap the FastAPI app with Granian's proxy header processor
10
+ # This enables real client IP detection when behind reverse proxies (nginx, Cloudflare, etc.)
11
+ # Only headers from trusted_proxy_hosts will be processed to prevent IP spoofing
12
+ app = wrap_asgi_with_proxy_headers(
13
+ _app, trusted_hosts=settings.trusted_proxy_hosts_list
14
+ )