vibetuner 2.6.1__py3-none-any.whl → 2.7.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.

Potentially problematic release.


This version of vibetuner might be problematic. Click here for more details.

Files changed (85) hide show
  1. vibetuner/__init__.py +2 -0
  2. vibetuner/__main__.py +4 -0
  3. vibetuner/cli/__init__.py +68 -0
  4. vibetuner/cli/run.py +161 -0
  5. vibetuner/config.py +128 -0
  6. vibetuner/context.py +25 -0
  7. vibetuner/frontend/AGENTS.md +113 -0
  8. vibetuner/frontend/CLAUDE.md +113 -0
  9. vibetuner/frontend/__init__.py +94 -0
  10. vibetuner/frontend/context.py +10 -0
  11. vibetuner/frontend/deps.py +41 -0
  12. vibetuner/frontend/email.py +45 -0
  13. vibetuner/frontend/hotreload.py +13 -0
  14. vibetuner/frontend/lifespan.py +26 -0
  15. vibetuner/frontend/middleware.py +151 -0
  16. vibetuner/frontend/oauth.py +196 -0
  17. vibetuner/frontend/routes/__init__.py +12 -0
  18. vibetuner/frontend/routes/auth.py +150 -0
  19. vibetuner/frontend/routes/debug.py +414 -0
  20. vibetuner/frontend/routes/health.py +33 -0
  21. vibetuner/frontend/routes/language.py +43 -0
  22. vibetuner/frontend/routes/meta.py +55 -0
  23. vibetuner/frontend/routes/user.py +94 -0
  24. vibetuner/frontend/templates.py +176 -0
  25. vibetuner/logging.py +87 -0
  26. vibetuner/models/AGENTS.md +165 -0
  27. vibetuner/models/CLAUDE.md +165 -0
  28. vibetuner/models/__init__.py +14 -0
  29. vibetuner/models/blob.py +89 -0
  30. vibetuner/models/email_verification.py +84 -0
  31. vibetuner/models/mixins.py +76 -0
  32. vibetuner/models/oauth.py +57 -0
  33. vibetuner/models/registry.py +15 -0
  34. vibetuner/models/types.py +16 -0
  35. vibetuner/models/user.py +91 -0
  36. vibetuner/mongo.py +18 -0
  37. vibetuner/paths.py +112 -0
  38. vibetuner/services/AGENTS.md +104 -0
  39. vibetuner/services/CLAUDE.md +104 -0
  40. vibetuner/services/__init__.py +0 -0
  41. vibetuner/services/blob.py +175 -0
  42. vibetuner/services/email.py +50 -0
  43. vibetuner/tasks/AGENTS.md +98 -0
  44. vibetuner/tasks/CLAUDE.md +98 -0
  45. vibetuner/tasks/__init__.py +2 -0
  46. vibetuner/tasks/context.py +34 -0
  47. vibetuner/tasks/worker.py +18 -0
  48. vibetuner/templates/email/AGENTS.md +48 -0
  49. vibetuner/templates/email/CLAUDE.md +48 -0
  50. vibetuner/templates/email/default/magic_link.html.jinja +16 -0
  51. vibetuner/templates/email/default/magic_link.txt.jinja +5 -0
  52. vibetuner/templates/frontend/AGENTS.md +74 -0
  53. vibetuner/templates/frontend/CLAUDE.md +74 -0
  54. vibetuner/templates/frontend/base/favicons.html.jinja +1 -0
  55. vibetuner/templates/frontend/base/footer.html.jinja +3 -0
  56. vibetuner/templates/frontend/base/header.html.jinja +0 -0
  57. vibetuner/templates/frontend/base/opengraph.html.jinja +7 -0
  58. vibetuner/templates/frontend/base/skeleton.html.jinja +42 -0
  59. vibetuner/templates/frontend/debug/collections.html.jinja +103 -0
  60. vibetuner/templates/frontend/debug/components/debug_nav.html.jinja +55 -0
  61. vibetuner/templates/frontend/debug/index.html.jinja +83 -0
  62. vibetuner/templates/frontend/debug/info.html.jinja +256 -0
  63. vibetuner/templates/frontend/debug/users.html.jinja +137 -0
  64. vibetuner/templates/frontend/debug/version.html.jinja +53 -0
  65. vibetuner/templates/frontend/email/magic_link.txt.jinja +5 -0
  66. vibetuner/templates/frontend/email_sent.html.jinja +82 -0
  67. vibetuner/templates/frontend/index.html.jinja +19 -0
  68. vibetuner/templates/frontend/lang/select.html.jinja +4 -0
  69. vibetuner/templates/frontend/login.html.jinja +84 -0
  70. vibetuner/templates/frontend/meta/browserconfig.xml.jinja +10 -0
  71. vibetuner/templates/frontend/meta/robots.txt.jinja +3 -0
  72. vibetuner/templates/frontend/meta/site.webmanifest.jinja +7 -0
  73. vibetuner/templates/frontend/meta/sitemap.xml.jinja +6 -0
  74. vibetuner/templates/frontend/user/edit.html.jinja +85 -0
  75. vibetuner/templates/frontend/user/profile.html.jinja +156 -0
  76. vibetuner/templates/markdown/.placeholder +0 -0
  77. vibetuner/templates/markdown/AGENTS.md +29 -0
  78. vibetuner/templates/markdown/CLAUDE.md +29 -0
  79. vibetuner/templates.py +152 -0
  80. vibetuner/time.py +57 -0
  81. vibetuner/versioning.py +8 -0
  82. {vibetuner-2.6.1.dist-info → vibetuner-2.7.0.dist-info}/METADATA +2 -1
  83. vibetuner-2.7.0.dist-info/RECORD +84 -0
  84. vibetuner-2.6.1.dist-info/RECORD +0 -4
  85. {vibetuner-2.6.1.dist-info → vibetuner-2.7.0.dist-info}/WHEEL +0 -0
@@ -0,0 +1,89 @@
1
+ """Blob storage model for file uploads and management.
2
+
3
+ WARNING: This is a scaffolding-managed file. DO NOT MODIFY directly.
4
+ Manages file metadata for S3 or local storage backends.
5
+ """
6
+
7
+ import hashlib
8
+ from enum import StrEnum
9
+ from typing import Self
10
+
11
+ from beanie import Document
12
+ from pydantic import Field
13
+
14
+ from vibetuner.models.registry import register_model
15
+
16
+ from .mixins import TimeStampMixin
17
+
18
+
19
+ class BlobStatus(StrEnum):
20
+ PENDING = "pending"
21
+ UPLOADED = "uploaded"
22
+ DELETED = "deleted"
23
+ ERROR = "error"
24
+
25
+
26
+ @register_model
27
+ class BlobModel(Document, TimeStampMixin):
28
+ status: BlobStatus = Field(
29
+ default=BlobStatus.PENDING,
30
+ description="Status of the blob indicating if it is pending, uploaded, or deleted",
31
+ )
32
+ bucket: str = Field(
33
+ ...,
34
+ description="Storage bucket name where the object is stored",
35
+ )
36
+ content_type: str = Field(
37
+ ...,
38
+ description="MIME type of the object (e.g., image/png, application/pdf)",
39
+ )
40
+ original_filename: str | None = Field(
41
+ default=None,
42
+ description="Original name of the file as uploaded by the user (if applicable)",
43
+ )
44
+ namespace: str | None = Field(
45
+ default=None,
46
+ description="Namespaces (prefixes) for the object, used for organization (optional)",
47
+ )
48
+ checksum: str = Field(
49
+ ...,
50
+ description="SHA256 hash of the object for integrity verification",
51
+ )
52
+ size: int = Field(..., description="Size of the object in bytes")
53
+
54
+ @property
55
+ def full_path(self) -> str:
56
+ """Get the full path of the blob in the bucket."""
57
+ if self.namespace:
58
+ return f"{self.namespace}/{self.id}"
59
+ else:
60
+ return f"{self.id}"
61
+
62
+ @classmethod
63
+ def from_bytes(
64
+ cls,
65
+ body: bytes,
66
+ content_type: str,
67
+ bucket: str,
68
+ namespace: str | None = None,
69
+ original_filename: str | None = None,
70
+ ) -> Self:
71
+ """Create a BlobModel instance from raw bytes and metadata."""
72
+ return cls(
73
+ original_filename=original_filename,
74
+ content_type=content_type,
75
+ bucket=bucket,
76
+ namespace=namespace,
77
+ checksum=cls.calculate_checksum(body),
78
+ size=len(body),
79
+ )
80
+
81
+ @staticmethod
82
+ def calculate_checksum(body: bytes) -> str:
83
+ """Calculate SHA256 checksum of the given bytes."""
84
+
85
+ return hashlib.sha256(body).hexdigest()
86
+
87
+ class Settings:
88
+ name = "blobs"
89
+ keep_nulls = False
@@ -0,0 +1,84 @@
1
+ """Email verification model for magic link authentication.
2
+
3
+ WARNING: This is a scaffolding-managed file. DO NOT MODIFY directly.
4
+ Handles passwordless authentication via email verification tokens.
5
+ """
6
+
7
+ import secrets
8
+ from datetime import datetime, timedelta
9
+ from typing import Optional, Self
10
+
11
+ from beanie import Document
12
+ from beanie.operators import Eq, Set
13
+ from pydantic import Field
14
+
15
+ from vibetuner.models.registry import register_model
16
+ from vibetuner.time import now
17
+
18
+
19
+ # Email verification token model
20
+ @register_model
21
+ class EmailVerificationTokenModel(Document):
22
+ email: str = Field(
23
+ ...,
24
+ description="Email address requesting verification",
25
+ )
26
+ token: str = Field(
27
+ ...,
28
+ description="Secure random token for email verification",
29
+ )
30
+ expires_at: datetime = Field(
31
+ ...,
32
+ description="Token expiration timestamp",
33
+ )
34
+ used: bool = Field(
35
+ default=False,
36
+ description="Whether the token has been consumed",
37
+ )
38
+
39
+ class Settings:
40
+ name = "email_verification_tokens"
41
+ indexes = [
42
+ [("token", 1)],
43
+ [("email", 1)],
44
+ [("expires_at", 1)],
45
+ ]
46
+
47
+ @classmethod
48
+ async def create_token(cls, email: str, expires_minutes: int = 15) -> Self:
49
+ """Create a new verification token for email login"""
50
+ token = secrets.token_urlsafe(32)
51
+ expires_at = now() + timedelta(minutes=expires_minutes)
52
+
53
+ # Invalidate any existing tokens for this email
54
+ await cls.find(Eq(cls.email, email)).update_many(Set({cls.used: True}))
55
+
56
+ verification_token = cls(
57
+ email=email, token=token, expires_at=expires_at, used=False
58
+ )
59
+
60
+ return await verification_token.insert()
61
+
62
+ @classmethod
63
+ async def verify_token(cls, token: str) -> Optional[Self]:
64
+ """Verify and consume a token"""
65
+ verification_token: Optional[Self] = await cls.find_one(
66
+ Eq(cls.token, token), Eq(cls.used, False)
67
+ )
68
+
69
+ if not verification_token:
70
+ return None
71
+
72
+ # Ensure expires_at is timezone-aware for comparison
73
+ expires_at = verification_token.expires_at
74
+ if expires_at.tzinfo is None:
75
+ from datetime import timezone
76
+
77
+ expires_at = expires_at.replace(tzinfo=timezone.utc)
78
+
79
+ if expires_at < now():
80
+ return None
81
+
82
+ # Mark token as used
83
+ verification_token.used = True
84
+ return await verification_token.save()
@@ -0,0 +1,76 @@
1
+ """Reusable model mixins for common functionality.
2
+
3
+ WARNING: This is a scaffolding-managed file. DO NOT MODIFY directly.
4
+ Provides timestamp tracking and other common model behaviors.
5
+ """
6
+
7
+ from datetime import datetime, timedelta
8
+ from enum import StrEnum
9
+ from typing import Self
10
+
11
+ from beanie import Insert, Replace, Save, SaveChanges, Update, before_event
12
+ from pydantic import BaseModel, Field
13
+
14
+ from vibetuner.time import Unit, now
15
+
16
+
17
+ class Since(StrEnum):
18
+ """Reference moment for age calculations."""
19
+
20
+ CREATION = "creation"
21
+ UPDATE = "update"
22
+
23
+
24
+ # ────────────────────────────────────────────────────────────────
25
+ # Drop-in mixin
26
+ # ────────────────────────────────────────────────────────────────
27
+ class TimeStampMixin(BaseModel):
28
+ """
29
+ ✦ Automatic UTC timestamps on insert/update
30
+ ✦ Typed helpers for age checks
31
+
32
+ doc.age() → timedelta
33
+ doc.age_in(Unit.HOURS) → float
34
+ doc.is_older_than(td, since=…) → bool
35
+ """
36
+
37
+ db_insert_dt: datetime = Field(
38
+ default_factory=lambda: now(),
39
+ description="Timestamp when the document was first created and inserted into the database (UTC)",
40
+ )
41
+ db_update_dt: datetime = Field(
42
+ default_factory=lambda: now(),
43
+ description="Timestamp when the document was last modified or updated (UTC)",
44
+ )
45
+
46
+ # ── Beanie hooks ────────────────────────────────────────────
47
+ @before_event(Insert)
48
+ def _touch_on_insert(self) -> None:
49
+ _now = now()
50
+ self.db_insert_dt = _now
51
+ self.db_update_dt = _now
52
+
53
+ @before_event(Update, SaveChanges, Save, Replace)
54
+ def _touch_on_update(self) -> None:
55
+ self.db_update_dt = now()
56
+
57
+ # ── Public helpers ──────────────────────────────────────────
58
+ def age(self, *, since: Since = Since.CREATION) -> timedelta:
59
+ """Timedelta since *creation* or last *update* (default: creation)."""
60
+ ref = self.db_update_dt if since is Since.UPDATE else self.db_insert_dt
61
+ return now() - ref
62
+
63
+ def age_in(
64
+ self, unit: Unit = Unit.SECONDS, *, since: Since = Since.CREATION
65
+ ) -> float:
66
+ """Age expressed as a float in the requested `unit`."""
67
+ return self.age(since=since).total_seconds() / unit.factor
68
+
69
+ def is_older_than(self, delta: timedelta, *, since: Since = Since.CREATION) -> bool:
70
+ """True iff the document’s age ≥ `delta`."""
71
+ return self.age(since=since) >= delta
72
+
73
+ def touch(self) -> Self:
74
+ """Manually bump `db_update_dt` and return `self` (chain-friendly)."""
75
+ self.db_update_dt = now()
76
+ return self
@@ -0,0 +1,57 @@
1
+ from typing import Self
2
+
3
+ from beanie import Document
4
+ from beanie.operators import Eq
5
+ from pydantic import BaseModel, Field
6
+
7
+ from vibetuner.models.registry import register_model
8
+
9
+ from .mixins import TimeStampMixin
10
+
11
+
12
+ class OauthProviderModel(BaseModel):
13
+ identifier: str
14
+ params: dict[str, str] = {}
15
+ client_kwargs: dict[str, str]
16
+ config: dict[str, str]
17
+
18
+
19
+ @register_model
20
+ class OAuthAccountModel(Document, TimeStampMixin):
21
+ provider: str = Field(
22
+ ...,
23
+ description="OAuth provider name (google, github, twitter, etc.)",
24
+ )
25
+ provider_user_id: str = Field(
26
+ ...,
27
+ description="Unique user identifier from the OAuth provider",
28
+ )
29
+ email: str | None = Field(
30
+ default=None,
31
+ description="Email address retrieved from OAuth provider profile",
32
+ )
33
+ name: str | None = Field(
34
+ default=None,
35
+ description="Full display name retrieved from OAuth provider profile",
36
+ )
37
+ picture: str | None = Field(
38
+ default=None,
39
+ description="Profile picture URL retrieved from OAuth provider",
40
+ )
41
+
42
+ class Settings:
43
+ name = "oauth_accounts"
44
+ indexes = [
45
+ [("provider", 1), ("provider_user_id", 1)],
46
+ ]
47
+
48
+ @classmethod
49
+ async def get_by_provider_and_id(
50
+ cls,
51
+ provider: str,
52
+ provider_user_id: str,
53
+ ) -> Self | None:
54
+ return await cls.find_one(
55
+ Eq(cls.provider, provider),
56
+ Eq(cls.provider_user_id, provider_user_id),
57
+ )
@@ -0,0 +1,15 @@
1
+ from beanie import Document, View
2
+
3
+
4
+ _MODEL_REGISTRY: list[type[Document] | type[View]] = []
5
+
6
+
7
+ def register_model(cls):
8
+ """Decorator to register a model"""
9
+ _MODEL_REGISTRY.append(cls)
10
+ return cls
11
+
12
+
13
+ def get_all_models():
14
+ """Get all registered models (call at startup)"""
15
+ return _MODEL_REGISTRY
@@ -0,0 +1,16 @@
1
+ """Common type definitions for models.
2
+
3
+ WARNING: This is a scaffolding-managed file. DO NOT MODIFY directly.
4
+ Provides type aliases and re-exports for consistent typing across models.
5
+ """
6
+
7
+ from typing import TYPE_CHECKING, TypeAlias, TypeVar
8
+
9
+ from beanie import Document, Link as BeanieLink
10
+
11
+
12
+ if TYPE_CHECKING:
13
+ _T = TypeVar("_T", bound=Document)
14
+ Link: TypeAlias = _T
15
+ else:
16
+ Link = BeanieLink
@@ -0,0 +1,91 @@
1
+ """Core user model for authentication and user management.
2
+
3
+ WARNING: This is a scaffolding-managed file. DO NOT MODIFY directly.
4
+ Extend functionality by creating custom models that reference or extend these models.
5
+ """
6
+
7
+ from functools import cached_property
8
+ from typing import Any, List, Self
9
+
10
+ from beanie import Document
11
+ from beanie.operators import Eq
12
+ from pydantic import BaseModel, Field
13
+ from pydantic_extra_types.language_code import LanguageAlpha2
14
+
15
+ from vibetuner.models.registry import register_model
16
+
17
+ from .mixins import TimeStampMixin
18
+ from .oauth import OAuthAccountModel
19
+ from .types import Link
20
+
21
+
22
+ class UserSettings(BaseModel):
23
+ """User settings for the application.
24
+
25
+ This class holds the default settings for the user, such as language and theme.
26
+ It can be extended to include more user-specific settings in the future.
27
+ """
28
+
29
+ language: LanguageAlpha2 | None = Field(
30
+ default=None,
31
+ description="Preferred language for the user",
32
+ )
33
+
34
+ @cached_property
35
+ def session_dict(self) -> dict[str, Any]:
36
+ """Return a dictionary representation of the user settings for session storage.
37
+
38
+ Make sure to only include fields that are necessary for the session.
39
+ """
40
+ return self.model_dump(
41
+ exclude_none=True,
42
+ exclude_unset=True,
43
+ include={
44
+ "language",
45
+ },
46
+ )
47
+
48
+
49
+ @register_model
50
+ class UserModel(Document, TimeStampMixin):
51
+ email: str | None = Field(
52
+ default=None,
53
+ description="Primary email address for authentication",
54
+ )
55
+ name: str | None = Field(
56
+ default=None,
57
+ description="User's full display name",
58
+ )
59
+ picture: str | None = Field(
60
+ default=None,
61
+ description="URL to user's profile picture or avatar",
62
+ )
63
+ oauth_accounts: List[Link[OAuthAccountModel]] = Field(
64
+ default_factory=list,
65
+ description="Connected OAuth provider accounts (Google, GitHub, etc.)",
66
+ )
67
+
68
+ user_settings: UserSettings = Field(
69
+ default_factory=UserSettings,
70
+ description="User-specific settings for the application",
71
+ )
72
+
73
+ class Settings:
74
+ name = "users"
75
+ keep_nulls = False
76
+
77
+ @cached_property
78
+ def session_dict(self) -> dict[str, Any]:
79
+ return {
80
+ "id": str(self.id),
81
+ **self.model_dump(
82
+ exclude_none=True,
83
+ exclude_unset=True,
84
+ include={"name", "email", "picture"},
85
+ ),
86
+ "settings": self.user_settings.session_dict,
87
+ }
88
+
89
+ @classmethod
90
+ async def get_by_email(cls, email: str) -> Self | None:
91
+ return await cls.find_one(Eq(cls.email, email))
vibetuner/mongo.py ADDED
@@ -0,0 +1,18 @@
1
+ from beanie import init_beanie
2
+ from pymongo import AsyncMongoClient
3
+
4
+ from vibetuner.config import settings
5
+ from vibetuner.models.registry import get_all_models
6
+
7
+
8
+ async def init_models() -> None:
9
+ """Initialize MongoDB connection and register all Beanie models."""
10
+
11
+ client: AsyncMongoClient = AsyncMongoClient(
12
+ host=str(settings.project.mongodb_url),
13
+ compressors=["zstd"],
14
+ )
15
+
16
+ await init_beanie(
17
+ database=client[settings.mongo_dbname], document_models=get_all_models()
18
+ )
vibetuner/paths.py ADDED
@@ -0,0 +1,112 @@
1
+ from importlib.resources import files
2
+ from pathlib import Path
3
+
4
+ # Package-relative paths (for bundled templates in the vibetuner package)
5
+ _package_files = files("vibetuner")
6
+ _package_templates_traversable = _package_files / "templates"
7
+
8
+ # Convert to Path when actually used (handles both filesystem and zip-based packages)
9
+ def _get_package_templates_path() -> Path:
10
+ """Get package templates path, works for both installed and editable installs."""
11
+ # For most cases, we can convert directly to Path
12
+ # For zip files, importlib.resources handles extraction automatically
13
+ try:
14
+ return Path(str(_package_templates_traversable))
15
+ except (TypeError, ValueError):
16
+ # If we can't convert to Path, we're in a zip or similar
17
+ # In this case, we'll need to use as_file() context manager when accessing
18
+ # For now, raise an error - we can enhance this later if needed
19
+ raise RuntimeError(
20
+ "Package templates are in a non-filesystem location. "
21
+ "This is not yet supported."
22
+ )
23
+
24
+
25
+ package_templates = _get_package_templates_path()
26
+
27
+ # Project root (set at runtime by the application using vibetuner)
28
+ # When None, only package templates are available
29
+ root: Path | None = None
30
+ fallback_path = "defaults"
31
+
32
+
33
+ def set_project_root(project_root: Path) -> None:
34
+ """Set the project root directory for the application using vibetuner.
35
+
36
+ This enables access to project-specific templates, assets, and locales.
37
+ Must be called before accessing project-specific paths.
38
+ """
39
+ global root, templates, app_templates, locales, config_vars
40
+ global assets, statics, css, js, favicons, img
41
+ global frontend_templates, email_templates, markdown_templates
42
+
43
+ root = project_root
44
+
45
+ # Update project-specific paths
46
+ templates = root / "templates"
47
+ app_templates = templates # Deprecated: projects now use templates/ directly
48
+ locales = root / "locales"
49
+ config_vars = root / ".copier-answers.yml"
50
+
51
+ # Update asset paths
52
+ assets = root / "assets"
53
+ statics = assets / "statics"
54
+ css = statics / "css"
55
+ js = statics / "js"
56
+ favicons = statics / "favicons"
57
+ img = statics / "img"
58
+
59
+ # Update template lists to include project overrides
60
+ frontend_templates = [templates / "frontend", package_templates / "frontend"]
61
+ email_templates = [templates / "email", package_templates / "email"]
62
+ markdown_templates = [templates / "markdown", package_templates / "markdown"]
63
+
64
+
65
+ def to_template_path_list(path: Path) -> list[Path]:
66
+ return [
67
+ path,
68
+ path / fallback_path,
69
+ ]
70
+
71
+
72
+ def fallback_static_default(static_type: str, file_name: str) -> Path:
73
+ """Return a fallback path for a file."""
74
+ if root is None:
75
+ raise RuntimeError(
76
+ "Project root not set. Call set_project_root() before accessing assets."
77
+ )
78
+
79
+ paths_to_check = [
80
+ statics / static_type / file_name,
81
+ statics / fallback_path / static_type / file_name,
82
+ ]
83
+
84
+ for path in paths_to_check:
85
+ if path.exists():
86
+ return path
87
+
88
+ raise FileNotFoundError(
89
+ f"Could not find {file_name} in any of the fallback paths: {paths_to_check}"
90
+ )
91
+
92
+
93
+ # Core templates point to package (always available)
94
+ core_templates = package_templates
95
+
96
+ # Template paths - initially only package templates, updated when set_project_root() is called
97
+ frontend_templates = [package_templates / "frontend"]
98
+ email_templates = [package_templates / "email"]
99
+ markdown_templates = [package_templates / "markdown"]
100
+
101
+ # Project-specific paths - will be None until set_project_root() is called
102
+ # These get updated by set_project_root()
103
+ templates: Path | None = None
104
+ app_templates: Path | None = None
105
+ locales: Path | None = None
106
+ config_vars: Path | None = None
107
+ assets: Path | None = None
108
+ statics: Path | None = None
109
+ css: Path | None = None
110
+ js: Path | None = None
111
+ favicons: Path | None = None
112
+ img: Path | None = None
@@ -0,0 +1,104 @@
1
+ # Core Services Module
2
+
3
+ **IMMUTABLE SCAFFOLDING CODE** - These are the framework's core services that provide essential functionality.
4
+
5
+ ## What's Here
6
+
7
+ This module contains the scaffolding's core services:
8
+
9
+ - **email.py** - Email sending via AWS SES
10
+ - **blob.py** - File storage and blob management
11
+
12
+ ## Important Rules
13
+
14
+ ⚠️ **DO NOT MODIFY** these core services directly.
15
+
16
+ **For changes to core services:**
17
+
18
+ - File an issue at `https://github.com/alltuner/scaffolding`
19
+ - Core changes benefit all projects using the scaffolding
20
+
21
+ **For your application services:**
22
+
23
+ - Create them in `src/app/services/` instead
24
+ - Import core services when needed: `from vibetuner.services.email import send_email`
25
+
26
+ ## User Service Pattern (for reference)
27
+
28
+ Your application services in `src/app/services/` should follow this pattern:
29
+
30
+ ```python
31
+ from vibetuner.models import UserModel
32
+
33
+ class NotificationService:
34
+ async def send_notification(
35
+ self,
36
+ user: UserModel,
37
+ message: str,
38
+ priority: str = "normal"
39
+ ) -> bool:
40
+ # Implementation
41
+ return True
42
+
43
+ # Singleton
44
+ notification_service = NotificationService()
45
+ ```
46
+
47
+ ## Using Core Services
48
+
49
+ ### Email Service
50
+
51
+ ```python
52
+ from vibetuner.services.email import send_email
53
+
54
+ await send_email(
55
+ to_email="user@example.com",
56
+ subject="Welcome",
57
+ html_content="<h1>Welcome!</h1>",
58
+ text_content="Welcome!"
59
+ )
60
+ ```
61
+
62
+ ### Blob Service
63
+
64
+ ```python
65
+ from vibetuner.services.blob import blob_service
66
+
67
+ # Upload file
68
+ blob = await blob_service.upload(file_data, "image.png")
69
+
70
+ # Get file URL
71
+ url = await blob_service.get_url(blob.id)
72
+ ```
73
+
74
+ ## Creating Your Own Services
75
+
76
+ Place your application services in `src/app/services/`:
77
+
78
+ ```python
79
+ # src/app/services/external_api.py
80
+ import httpx
81
+
82
+ async def call_api(api_url: str, api_key: str, data: dict) -> dict:
83
+ async with httpx.AsyncClient() as client:
84
+ response = await client.post(
85
+ api_url,
86
+ json=data,
87
+ headers={"Authorization": f"Bearer {api_key}"}
88
+ )
89
+ response.raise_for_status()
90
+ return response.json()
91
+ ```
92
+
93
+ ## Dependency Injection
94
+
95
+ ```python
96
+ from fastapi import Depends
97
+
98
+ @router.post("/notify")
99
+ async def notify(
100
+ message: str,
101
+ service=Depends(lambda: notification_service)
102
+ ):
103
+ await service.send_notification(user, message)
104
+ ```