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,84 @@
1
+ {% extends "base/skeleton.html.jinja" %}
2
+ {% block title %}
3
+ {{ _("Sign In") }}
4
+ {% endblock title %}
5
+ {% block body %}
6
+ <div class="min-h-screen bg-gradient-to-br from-primary to-secondary flex items-center justify-center p-4">
7
+ <div class="card w-full max-w-md bg-base-100 shadow-2xl backdrop-blur-sm bg-opacity-95">
8
+ <div class="card-body">
9
+ <!-- Header -->
10
+ <div class="text-center mb-8">
11
+ <h1 class="text-3xl font-bold text-base-content mb-2">{{ _("Welcome Back") }}</h1>
12
+ <p class="text-base-content/70">{{ _("Choose your preferred sign-in method") }}</p>
13
+ </div>
14
+ <!-- OAuth Providers -->
15
+ {% if has_oauth and providers %}
16
+ <div class="space-y-3 mb-6">
17
+ {% for provider in providers %}
18
+ <a href="{{ url_for('login_with_' + provider) }}{%- if next %}?next={{ next }}{%- endif %}"
19
+ class="btn btn-outline btn-block justify-start gap-3 hover:btn-primary group transition-all duration-300 hover:scale-[1.02]">
20
+ <span>{{ _("Continue with %(provider)s") | format(provider=provider.title()) }}</span>
21
+ </a>
22
+ {% endfor %}
23
+ </div>
24
+ {% endif %}
25
+ <!-- Divider -->
26
+ {% if has_oauth and providers and has_email %}<div class="divider text-base-content/50">{{ _("OR") }}</div>{% endif %}
27
+ <!-- Email Magic Link -->
28
+ {% if has_email %}
29
+ <div class="bg-base-200/50 rounded-2xl p-6 border border-base-300/50">
30
+ <form method="post"
31
+ class="space-y-4"
32
+ action="{{ url_for("send_magic_link") }}">
33
+ {% if next %}<input type="hidden" name="next" value="{{ next }}" />{% endif %}
34
+ <div class="form-control">
35
+ <label class="label" for="email">
36
+ <span class="label-text font-medium">{{ _("Email Address") }}</span>
37
+ </label>
38
+ <div class="relative">
39
+ <input type="email"
40
+ id="email"
41
+ name="email"
42
+ class="input input-bordered w-full pr-12 focus:input-primary transition-all duration-300"
43
+ placeholder="{{ _("Enter your email address") }}"
44
+ required
45
+ autocomplete="email" />
46
+ <div class="absolute inset-y-0 right-0 flex items-center pr-3">
47
+ <svg class="w-5 h-5 text-base-content/40"
48
+ fill="none"
49
+ stroke="currentColor"
50
+ viewBox="0 0 24 24">
51
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
52
+ </svg>
53
+ </div>
54
+ </div>
55
+ </div>
56
+ <button type="submit"
57
+ class="btn btn-primary btn-block gap-2 group hover:scale-[1.02] transition-all duration-300">
58
+ <svg class="w-5 h-5 group-hover:rotate-12 transition-transform"
59
+ fill="none"
60
+ stroke="currentColor"
61
+ viewBox="0 0 24 24">
62
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
63
+ </svg>
64
+ <span>{{ _("Send Magic Link") }}</span>
65
+ </button>
66
+ <div class="alert alert-info bg-info/10 border-info/20">
67
+ <svg class="w-5 h-5 text-info shrink-0"
68
+ fill="none"
69
+ stroke="currentColor"
70
+ viewBox="0 0 24 24">
71
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
72
+ </svg>
73
+ <div class="text-sm">
74
+ <p class="font-medium text-info">{{ _("No password required!") }}</p>
75
+ <p class="text-info/80">{{ _("We'll send you a secure link to sign in instantly.") }}</p>
76
+ </div>
77
+ </div>
78
+ </form>
79
+ </div>
80
+ {% endif %}
81
+ </div>
82
+ </div>
83
+ </div>
84
+ {% endblock body %}
@@ -0,0 +1,10 @@
1
+ <?xml version="1.0" encoding="utf-8"?>
2
+ <browserconfig>
3
+ <msapplication>
4
+ <tile>
5
+ <square150x150logo
6
+ src="{{ url_for('favicons', path='mstile-150x150.png').path }}" />
7
+ <TileColor>#b91d47</TileColor>
8
+ </tile>
9
+ </msapplication>
10
+ </browserconfig>
@@ -0,0 +1,3 @@
1
+ User-agent: *
2
+ Disallow: /auth
3
+ Disallow: /user
@@ -0,0 +1,7 @@
1
+ {
2
+ "name": "{{ project_name }}",
3
+ "short_name": "{{ project_slug }}",
4
+ "theme_color": "#ffffff",
5
+ "background_color": "#ffffff",
6
+ "display": "standalone"
7
+ }
@@ -0,0 +1,6 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
3
+ <url>
4
+ <loc>https://{{ fqdn }}/</loc>
5
+ </url>
6
+ </urlset>
@@ -0,0 +1,85 @@
1
+ {% extends "base/skeleton.html.jinja" %}
2
+ {% block title %}
3
+ {{ _("Edit Profile") }}
4
+ {% endblock title %}
5
+ {% block body %}
6
+ <div class="min-h-screen bg-gray-50">
7
+ <div class="container mx-auto px-4 py-8">
8
+ <div class="max-w-2xl mx-auto">
9
+ <!-- Page Header -->
10
+ <div class="mb-8">
11
+ <h1 class="text-3xl font-bold text-gray-900">{{ _("Edit Profile") }}</h1>
12
+ <p class="text-gray-600 mt-2">{{ _("Update your account information and preferences") }}</p>
13
+ </div>
14
+ <!-- Edit Form -->
15
+ <div class="bg-white shadow-lg rounded-lg p-6">
16
+ <form method="post" action="{{ url_for("user_edit_submit") }}">
17
+ <div class="space-y-6">
18
+ <!-- Name Field -->
19
+ <div>
20
+ <label for="name" class="block text-sm font-medium text-gray-700 mb-2">{{ _("Display Name") }}</label>
21
+ <input type="text"
22
+ id="name"
23
+ name="name"
24
+ value="{{ user.name or '' }}"
25
+ class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
26
+ required />
27
+ <p class="text-sm text-gray-600 mt-1">{{ _("This name will be displayed throughout the application") }}</p>
28
+ </div>
29
+ <!-- Language Preference -->
30
+ <div>
31
+ <label for="language" class="block text-sm font-medium text-gray-700 mb-2">{{ _("Preferred Language") }}</label>
32
+ <select id="language"
33
+ name="language"
34
+ class="w-full px-4 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
35
+ <option value="">{{ _("Use browser default") }}</option>
36
+ {% for lang_code, lang_name in locale_names.items() %}
37
+ <option value="{{ lang_code }}"
38
+ {% if current_language and current_language == lang_code %}selected{% endif %}>
39
+ {{ lang_name }}
40
+ </option>
41
+ {% endfor %}
42
+ </select>
43
+ <p class="text-sm text-gray-600 mt-1">{{ _("This will override browser language detection") }}</p>
44
+ </div>
45
+ <!-- Email (read-only) -->
46
+ <div>
47
+ <label for="email" class="block text-sm font-medium text-gray-700 mb-2">{{ _("Email Address") }}</label>
48
+ <input type="email"
49
+ id="email"
50
+ value="{{ user.email or '' }}"
51
+ class="w-full px-4 py-2 bg-gray-50 border border-gray-300 rounded-md text-gray-600"
52
+ readonly />
53
+ <p class="text-sm text-gray-600 mt-1">{{ _("Email cannot be changed and is managed through your OAuth provider") }}</p>
54
+ </div>
55
+ </div>
56
+ <!-- Form Actions -->
57
+ <div class="flex justify-between items-center mt-8 pt-6 border-t border-gray-200">
58
+ <a href="{{ url_for("user_profile") }}" class="btn btn-ghost">{{ _("Cancel") }}</a>
59
+ <button type="submit" class="btn btn-primary">{{ _("Save Changes") }}</button>
60
+ </div>
61
+ </form>
62
+ </div>
63
+ <!-- Additional Info Card -->
64
+ <div class="mt-6 bg-blue-50 border border-blue-200 rounded-lg p-4">
65
+ <div class="flex items-start">
66
+ <div class="flex-shrink-0">
67
+ <svg class="h-5 w-5 text-blue-600"
68
+ xmlns="http://www.w3.org/2000/svg"
69
+ viewBox="0 0 20 20"
70
+ fill="currentColor">
71
+ <path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
72
+ </svg>
73
+ </div>
74
+ <div class="ml-3">
75
+ <h3 class="text-sm font-medium text-blue-900">{{ _("About Language Preferences") }}</h3>
76
+ <p class="mt-1 text-sm text-blue-700">
77
+ {{ _("Your language preference will take priority over URL parameters, cookies, and browser settings. Leave empty to use automatic detection.") }}
78
+ </p>
79
+ </div>
80
+ </div>
81
+ </div>
82
+ </div>
83
+ </div>
84
+ </div>
85
+ {% endblock body %}
@@ -0,0 +1,156 @@
1
+ {% extends "base/skeleton.html.jinja" %}
2
+ {% block title %}
3
+ {{ _("User Profile") }}
4
+ {% endblock title %}
5
+ {% block body %}
6
+ <div class="min-h-screen bg-gray-50">
7
+ <div class="container mx-auto px-4 py-8">
8
+ <div class="max-w-4xl mx-auto">
9
+ <!-- Page Header -->
10
+ <div class="mb-8">
11
+ <h1 class="text-3xl font-bold text-gray-900">{{ _("User Profile") }}</h1>
12
+ <p class="text-gray-600 mt-2">{{ _("Manage your account information and settings") }}</p>
13
+ </div>
14
+ <!-- Profile Card -->
15
+ <div class="bg-white shadow-lg rounded-lg overflow-hidden">
16
+ <!-- Profile Header -->
17
+ <div class="bg-gradient-to-r from-blue-500 to-indigo-600 px-6 py-8">
18
+ <div class="flex items-center space-x-4">
19
+ <!-- Avatar -->
20
+ {% if user.picture or (user.oauth_accounts and user.oauth_accounts[0].picture) %}
21
+ <img src="{{ user.picture or user.oauth_accounts[0].picture }}"
22
+ alt="{{ user.email }}"
23
+ width="80"
24
+ height="80"
25
+ class="w-20 h-20 rounded-full object-cover border-4 border-white shadow-lg" />
26
+ {% else %}
27
+ <div class="w-20 h-20 bg-white rounded-full flex items-center justify-center text-3xl font-bold text-gray-700 shadow-lg">
28
+ {{ user.email[0].upper() if user.email }}
29
+ </div>
30
+ {% endif %}
31
+ <!-- User Info -->
32
+ <div class="text-white">
33
+ <h2 class="text-2xl font-semibold">{{ user.email }}</h2>
34
+ <p class="text-blue-100">{{ _("Member since") }} {{ user.db_insert_dt | timeago }}</p>
35
+ </div>
36
+ </div>
37
+ </div>
38
+ <!-- Profile Content -->
39
+ <div class="p-6">
40
+ <!-- Account Information Section -->
41
+ <div class="mb-8">
42
+ <h3 class="text-xl font-semibold text-gray-800 mb-4">{{ _("Account Information") }}</h3>
43
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
44
+ <!-- Email -->
45
+ <div>
46
+ <label class="block text-sm font-medium text-gray-700 mb-1">{{ _("Email Address") }}</label>
47
+ <div class="bg-gray-50 px-4 py-3 rounded-md">
48
+ <p class="text-gray-900">{{ user.email }}</p>
49
+ </div>
50
+ </div>
51
+ <!-- User ID -->
52
+ <div>
53
+ <label class="block text-sm font-medium text-gray-700 mb-1">{{ _("User ID") }}</label>
54
+ <div class="bg-gray-50 px-4 py-3 rounded-md">
55
+ <p class="text-gray-900 font-mono text-sm">{{ user.id }}</p>
56
+ </div>
57
+ </div>
58
+ <!-- Created Date -->
59
+ <div>
60
+ <label class="block text-sm font-medium text-gray-700 mb-1">{{ _("Account Created") }}</label>
61
+ <div class="bg-gray-50 px-4 py-3 rounded-md">
62
+ <p class="text-gray-900">{{ user.db_insert_dt | format_datetime }}</p>
63
+ </div>
64
+ </div>
65
+ <!-- Last Updated -->
66
+ <div>
67
+ <label class="block text-sm font-medium text-gray-700 mb-1">{{ _("Last Updated") }}</label>
68
+ <div class="bg-gray-50 px-4 py-3 rounded-md">
69
+ <p class="text-gray-900">{{ user.db_update_dt | format_datetime }}</p>
70
+ </div>
71
+ </div>
72
+ </div>
73
+ </div>
74
+ <!-- OAuth Accounts Section -->
75
+ {% if user.oauth_accounts %}
76
+ <div class="mb-8">
77
+ <h3 class="text-xl font-semibold text-gray-800 mb-4">{{ _("Connected Accounts") }}</h3>
78
+ <div class="space-y-3">
79
+ {% for account in user.oauth_accounts %}
80
+ <div class="flex items-center justify-between bg-gray-50 px-4 py-3 rounded-md">
81
+ <div class="flex items-center space-x-3">
82
+ {% if account.picture %}
83
+ <img src="{{ account.picture }}"
84
+ alt="{{ account.provider }}"
85
+ width="40"
86
+ height="40"
87
+ class="w-10 h-10 rounded-full object-cover" />
88
+ {% else %}
89
+ <div class="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
90
+ <span class="text-gray-600 font-medium">{{ account.provider[0].upper() }}</span>
91
+ </div>
92
+ {% endif %}
93
+ <div>
94
+ <p class="font-medium text-gray-900">{{ account.provider|title }}</p>
95
+ <p class="text-sm text-gray-600">{{ account.email or _("No email") }}</p>
96
+ </div>
97
+ </div>
98
+ <span class="text-sm text-green-600 font-medium">{{ _("Connected") }}</span>
99
+ </div>
100
+ {% endfor %}
101
+ </div>
102
+ </div>
103
+ {% endif %}
104
+ <!-- Actions -->
105
+ <div class="border-t pt-6">
106
+ <div class="flex flex-col sm:flex-row gap-4">
107
+ <button class="btn btn-primary"
108
+ hx-get="/user/edit"
109
+ hx-target="#main-content"
110
+ hx-swap="innerHTML">{{ _("Edit Profile") }}</button>
111
+ <a href="{{ url_for("auth_logout") }}" class="btn btn-outline btn-error">{{ _("Sign Out") }}</a>
112
+ </div>
113
+ </div>
114
+ </div>
115
+ </div>
116
+ <!-- Additional Settings Card -->
117
+ <div class="mt-6 bg-white shadow-lg rounded-lg p-6">
118
+ <h3 class="text-xl font-semibold text-gray-800 mb-4">{{ _("Account Settings") }}</h3>
119
+ <div class="space-y-4">
120
+ <!-- Language Setting -->
121
+ <div class="flex items-center justify-between">
122
+ <div>
123
+ <p class="font-medium text-gray-900">{{ _("Preferred Language") }}</p>
124
+ <p class="text-sm text-gray-600">{{ _("Your preferred language for the interface") }}</p>
125
+ </div>
126
+ <div class="text-gray-900">
127
+ {% if user.user_settings.language %}
128
+ <span class="badge badge-primary">{{ user.user_settings.language|upper }}</span>
129
+ {% else %}
130
+ <span class="text-gray-500">{{ _("Not set") }}</span>
131
+ {% endif %}
132
+ </div>
133
+ </div>
134
+ <!-- Additional settings can be added here as the UserSettings model is extended -->
135
+ <!-- Email Notifications -->
136
+ <div class="flex items-center justify-between">
137
+ <div>
138
+ <p class="font-medium text-gray-900">{{ _("Email Notifications") }}</p>
139
+ <p class="text-sm text-gray-600">{{ _("Receive updates about your account") }}</p>
140
+ </div>
141
+ <input type="checkbox" class="toggle toggle-primary" checked />
142
+ </div>
143
+ <!-- Privacy Settings -->
144
+ <div class="flex items-center justify-between">
145
+ <div>
146
+ <p class="font-medium text-gray-900">{{ _("Profile Visibility") }}</p>
147
+ <p class="text-sm text-gray-600">{{ _("Make your profile visible to others") }}</p>
148
+ </div>
149
+ <input type="checkbox" class="toggle toggle-primary" />
150
+ </div>
151
+ </div>
152
+ </div>
153
+ </div>
154
+ </div>
155
+ </div>
156
+ {% endblock body %}
File without changes
@@ -0,0 +1,29 @@
1
+ # Core Markdown Templates - DO NOT MODIFY
2
+
3
+ **⚠️ IMPORTANT**: Package-managed files. Changes will be lost on package updates.
4
+
5
+ ## How to Override
6
+
7
+ **NEVER modify files in this directory!** Instead:
8
+
9
+ 1. Copy template to your project's `templates/markdown/`
10
+ 2. Maintain the same directory structure
11
+ 3. Your version overrides automatically
12
+
13
+ ### Example
14
+
15
+ ```bash
16
+ # Core template (if exists, bundled in vibetuner package):
17
+ vibetuner/templates/markdown/default/template.md.jinja
18
+
19
+ # Your override (CREATE THIS in your project):
20
+ templates/markdown/default/template.md.jinja
21
+ ```
22
+
23
+ ## Template Structure
24
+
25
+ Currently, no core markdown templates are provided by the package.
26
+
27
+ This directory is a placeholder for potential future package markdown templates.
28
+
29
+ All markdown templates should be created in `templates/markdown/`.
@@ -0,0 +1,29 @@
1
+ # Core Markdown Templates - DO NOT MODIFY
2
+
3
+ **⚠️ IMPORTANT**: Package-managed files. Changes will be lost on package updates.
4
+
5
+ ## How to Override
6
+
7
+ **NEVER modify files in this directory!** Instead:
8
+
9
+ 1. Copy template to your project's `templates/markdown/`
10
+ 2. Maintain the same directory structure
11
+ 3. Your version overrides automatically
12
+
13
+ ### Example
14
+
15
+ ```bash
16
+ # Core template (if exists, bundled in vibetuner package):
17
+ vibetuner/templates/markdown/default/template.md.jinja
18
+
19
+ # Your override (CREATE THIS in your project):
20
+ templates/markdown/default/template.md.jinja
21
+ ```
22
+
23
+ ## Template Structure
24
+
25
+ Currently, no core markdown templates are provided by the package.
26
+
27
+ This directory is a placeholder for potential future package markdown templates.
28
+
29
+ All markdown templates should be created in `templates/markdown/`.
vibetuner/templates.py ADDED
@@ -0,0 +1,152 @@
1
+ from pathlib import Path
2
+ from typing import Any, Dict, Optional
3
+
4
+ from jinja2 import Environment, FileSystemLoader, TemplateNotFound
5
+
6
+ from .paths import (
7
+ app_templates,
8
+ core_templates,
9
+ email_templates,
10
+ frontend_templates,
11
+ markdown_templates,
12
+ )
13
+
14
+
15
+ def _get_base_paths_for_namespace(
16
+ namespace: str | None,
17
+ template_path: Path | list[Path] | None,
18
+ ) -> list[Path]:
19
+ """Get base template paths based on namespace and template_path."""
20
+ if template_path is not None:
21
+ return [template_path] if isinstance(template_path, Path) else template_path
22
+
23
+ # Map known namespaces to their predefined paths
24
+ if namespace == "email":
25
+ return email_templates
26
+ if namespace == "markdown":
27
+ return markdown_templates
28
+ if namespace == "frontend":
29
+ return frontend_templates
30
+
31
+ # Default for unknown or None namespace
32
+ # Only include app_templates if project root has been set
33
+ paths = []
34
+ if app_templates is not None:
35
+ paths.append(app_templates)
36
+ paths.append(core_templates)
37
+ return paths
38
+
39
+
40
+ def _build_search_paths(
41
+ base_paths: list[Path],
42
+ namespace: str | None,
43
+ template_path: Path | list[Path] | None,
44
+ ) -> list[Path]:
45
+ """Build list of directories to search for templates."""
46
+ search_paths: list[Path] = []
47
+ known_namespaces = ("email", "markdown", "frontend")
48
+
49
+ for base_path in base_paths:
50
+ # If namespace is known and we're using default paths, they already include it
51
+ if namespace in known_namespaces and template_path is None:
52
+ if base_path.is_dir():
53
+ search_paths.append(base_path)
54
+ elif namespace:
55
+ # Append namespace to path for custom namespaces or explicit paths
56
+ ns_path = base_path / namespace
57
+ if ns_path.is_dir():
58
+ search_paths.append(ns_path)
59
+ else:
60
+ if base_path.is_dir():
61
+ search_paths.append(base_path)
62
+
63
+ return search_paths
64
+
65
+
66
+ def _render_template_with_env(
67
+ env: Environment,
68
+ jinja_template_name: str,
69
+ lang: str | None,
70
+ context: dict[str, Any],
71
+ ) -> str:
72
+ """Render template using Jinja environment with language fallback."""
73
+ # Try language-specific folder first
74
+ if lang:
75
+ try:
76
+ template = env.get_template(f"{lang}/{jinja_template_name}")
77
+ return template.render(**context)
78
+ except TemplateNotFound:
79
+ pass
80
+
81
+ # Fallback to default folder
82
+ template = env.get_template(f"default/{jinja_template_name}")
83
+ return template.render(**context)
84
+
85
+
86
+ def render_static_template(
87
+ template_name: str,
88
+ *,
89
+ template_path: Path | list[Path] | None = None,
90
+ namespace: Optional[str] = None,
91
+ context: Optional[Dict[str, Any]] = None,
92
+ lang: Optional[str] = None,
93
+ ) -> str:
94
+ """Render a Jinja template with optional i18n and namespace support.
95
+
96
+ This simplified functional helper replaces the old ``TemplateRenderer``
97
+ class while adding **namespace** awareness:
98
+
99
+ 1. Optionally switch to *template_path / namespace* if that directory
100
+ exists, letting you segment templates per tenant, brand, or feature
101
+ module without changing call‑sites.
102
+ 2. Within the selected base directory attempt ``<lang>/<name>.jinja``
103
+ when *lang* is provided.
104
+ 3. Fallback to ``default/<name>.jinja``.
105
+
106
+ Args:
107
+ template_name: Base filename without extension (e.g. ``"invoice"``).
108
+ template_path: Root directory or list of directories containing template
109
+ collections. When a list is provided, searches in order (project templates
110
+ override package templates). Defaults to the package's bundled templates.
111
+ namespace: Optional subfolder under *template_path* to confine the
112
+ lookup. Ignored when the directory does not exist.
113
+ context: Variables passed to the template while rendering.
114
+ lang: Language code such as ``"en"`` or ``"es"`` for localized
115
+ templates.
116
+
117
+ Returns:
118
+ The rendered template as a string.
119
+
120
+ Raises:
121
+ TemplateNotFound: When no suitable template could be located after all
122
+ fallbacks.
123
+ """
124
+
125
+ context = context or {}
126
+
127
+ # Determine base paths from namespace and template_path
128
+ base_paths = _get_base_paths_for_namespace(namespace, template_path)
129
+
130
+ # Build search paths from base paths
131
+ search_paths = _build_search_paths(base_paths, namespace, template_path)
132
+
133
+ if not search_paths:
134
+ raise TemplateNotFound(
135
+ f"No valid template paths found for namespace '{namespace}'"
136
+ )
137
+
138
+ # Create Jinja environment with search paths
139
+ env = Environment( # noqa: S701
140
+ loader=FileSystemLoader(search_paths),
141
+ trim_blocks=True,
142
+ lstrip_blocks=True,
143
+ )
144
+
145
+ # Render template with language fallback
146
+ jinja_template_name = f"{template_name}.jinja"
147
+ try:
148
+ return _render_template_with_env(env, jinja_template_name, lang, context)
149
+ except TemplateNotFound as err:
150
+ raise TemplateNotFound(
151
+ f"Template '{jinja_template_name}' not found under '{search_paths}'."
152
+ ) from err
vibetuner/time.py ADDED
@@ -0,0 +1,57 @@
1
+ from datetime import (
2
+ UTC,
3
+ datetime,
4
+ timedelta,
5
+ )
6
+ from enum import StrEnum, auto
7
+
8
+
9
+ class Unit(StrEnum):
10
+ """Return units for `.age_in()`."""
11
+
12
+ SECONDS = auto()
13
+ MINUTES = auto()
14
+ HOURS = auto()
15
+ DAYS = auto()
16
+ WEEKS = auto()
17
+
18
+ @property
19
+ def factor(self) -> int:
20
+ return {
21
+ Unit.SECONDS: 1,
22
+ Unit.MINUTES: 60,
23
+ Unit.HOURS: 3_600,
24
+ Unit.DAYS: 86_400,
25
+ Unit.WEEKS: 604_800,
26
+ }[self]
27
+
28
+
29
+ def now() -> datetime:
30
+ return datetime.now(UTC)
31
+
32
+
33
+ def age_in_days(dt: datetime) -> int:
34
+ # Ensure dt is timezone-aware, if it isn't already
35
+ if dt.tzinfo is None:
36
+ dt = dt.replace(tzinfo=UTC)
37
+
38
+ return int((now() - dt).total_seconds() / 60 / 60 / 24)
39
+
40
+
41
+ def age_in_minutes(dt: datetime) -> int:
42
+ # Ensure dt is timezone-aware, if it isn't already
43
+ if dt.tzinfo is None:
44
+ dt = dt.replace(tzinfo=UTC)
45
+
46
+ return int((now() - dt).total_seconds() / 60)
47
+
48
+
49
+ def age_in_timedelta(dt: datetime) -> timedelta:
50
+ # Ensure dt is timezone-aware, if it isn't already
51
+ if dt.tzinfo is None:
52
+ dt = dt.replace(tzinfo=UTC)
53
+
54
+ return now() - dt
55
+
56
+
57
+ # Custom functions below
@@ -0,0 +1,8 @@
1
+ __version__ = "0.0.0-default"
2
+
3
+ try:
4
+ from .._version import version as __version__ # type: ignore
5
+ except ImportError:
6
+ pass
7
+
8
+ version = __version__
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: vibetuner
3
- Version: 2.6.1
3
+ Version: 2.7.0
4
4
  Summary: Blessed Python dependencies for Vibetuner projects
5
5
  Requires-Dist: aioboto3>=15.5.0
6
6
  Requires-Dist: arel>=0.4.0
@@ -34,6 +34,7 @@ Requires-Dist: gh-bin>=2.81.0 ; extra == 'dev'
34
34
  Requires-Dist: granian[pname,reload]>=2.5.6 ; extra == 'dev'
35
35
  Requires-Dist: just-bin>=1.43.0 ; extra == 'dev'
36
36
  Requires-Dist: pre-commit>=4.3.0 ; extra == 'dev'
37
+ Requires-Dist: pysemver>=0.5.0 ; extra == 'dev'
37
38
  Requires-Dist: ruff>=0.14.3 ; extra == 'dev'
38
39
  Requires-Dist: rumdl>=0.0.170 ; extra == 'dev'
39
40
  Requires-Dist: semver>=3.0.4 ; extra == 'dev'