vibetuner 2.18.1__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
@@ -1,5 +1,6 @@
1
1
  # ABOUTME: Core CLI setup with AsyncTyper wrapper and base configuration
2
2
  # ABOUTME: Provides main CLI entry point and logging configuration
3
+ import importlib.metadata
3
4
  import inspect
4
5
  from functools import partial, wraps
5
6
  from importlib import import_module
@@ -7,10 +8,11 @@ from importlib import import_module
7
8
  import asyncer
8
9
  import typer
9
10
  from rich.console import Console
11
+ from rich.table import Table
10
12
 
11
13
  from vibetuner.cli.run import run_app
12
14
  from vibetuner.cli.scaffold import scaffold_app
13
- from vibetuner.logging import LogLevel, setup_logging
15
+ from vibetuner.logging import LogLevel, logger, setup_logging
14
16
 
15
17
 
16
18
  console = Console()
@@ -69,11 +71,54 @@ def callback(log_level: LogLevel | None = LOG_LEVEL_OPTION) -> None:
69
71
  setup_logging(level=log_level)
70
72
 
71
73
 
74
+ @app.command()
75
+ def version(
76
+ show_app: bool = typer.Option(
77
+ False,
78
+ "--app",
79
+ "-a",
80
+ help="Show app settings version even if not in a project directory",
81
+ ),
82
+ ) -> None:
83
+ """Show version information."""
84
+ try:
85
+ # Get vibetuner package version
86
+ vibetuner_version = importlib.metadata.version("vibetuner")
87
+ except importlib.metadata.PackageNotFoundError:
88
+ vibetuner_version = "unknown"
89
+
90
+ # Create table for nice display
91
+ table = Table(title="Version Information")
92
+ table.add_column("Component", style="cyan", no_wrap=True)
93
+ table.add_column("Version", style="green", no_wrap=True)
94
+
95
+ # Always show vibetuner package version
96
+ table.add_row("vibetuner package", vibetuner_version)
97
+
98
+ # Show app version if requested or if in a project
99
+ try:
100
+ from vibetuner.config import CoreConfiguration
101
+
102
+ settings = CoreConfiguration()
103
+ table.add_row(f"{settings.project.project_name} settings", settings.version)
104
+ except Exception:
105
+ if show_app:
106
+ table.add_row("app settings", "not in project directory")
107
+ # else: don't show app version if not in project and not requested
108
+
109
+ console.print(table)
110
+
111
+
72
112
  app.add_typer(run_app, name="run")
73
113
  app.add_typer(scaffold_app, name="scaffold")
74
114
 
75
115
  try:
76
116
  import_module("app.cli")
77
- except (ImportError, ModuleNotFoundError):
117
+ except ModuleNotFoundError:
118
+ # Silent pass for missing app.cli module (expected in some projects)
78
119
  pass
79
- # Cache buster
120
+ except ImportError as e:
121
+ # Log warning for any import error (including syntax errors, missing dependencies, etc.)
122
+ logger.warning(
123
+ f"Failed to import app.cli: {e}. User CLI commands will not be available."
124
+ )
vibetuner/cli/run.py CHANGED
@@ -47,7 +47,7 @@ def dev(
47
47
 
48
48
  # Call streaq programmatically
49
49
  streaq_main(
50
- worker_path="app.tasks.worker.worker",
50
+ worker_path="vibetuner.tasks.worker.worker",
51
51
  workers=1,
52
52
  reload=True,
53
53
  verbose=True,
@@ -123,7 +123,7 @@ def prod(
123
123
 
124
124
  # Call streaq programmatically
125
125
  streaq_main(
126
- worker_path="app.tasks.worker.worker",
126
+ worker_path="vibetuner.tasks.worker.worker",
127
127
  workers=workers_count,
128
128
  reload=False,
129
129
  verbose=False,
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
  )
@@ -141,4 +153,3 @@ settings = CoreConfiguration()
141
153
 
142
154
 
143
155
  logger.info("Configuration loaded for project: {}", settings.project.project_name)
144
- logger.info("Configuration loaded for project: {}", settings.model_dump())
@@ -4,10 +4,12 @@ from fastapi import APIRouter, Depends as Depends, FastAPI, Request
4
4
  from fastapi.responses import HTMLResponse, RedirectResponse
5
5
  from fastapi.staticfiles import StaticFiles
6
6
 
7
+ import vibetuner.frontend.lifespan as lifespan_module
7
8
  from vibetuner import paths
9
+ from vibetuner.logging import logger
8
10
 
9
11
  from .deps import LangDep as LangDep, MagicCookieDep as MagicCookieDep
10
- from .lifespan import ctx, lifespan
12
+ from .lifespan import ctx
11
13
  from .middleware import middlewares
12
14
  from .routes import auth, debug, health, language, meta, user
13
15
  from .templates import render_template
@@ -21,15 +23,35 @@ def register_router(router: APIRouter) -> None:
21
23
 
22
24
 
23
25
  try:
24
- import app.frontend.oauth as _app_oauth # noqa: F401 # type: ignore[unresolved-import]
25
- import app.frontend.routes as _app_routes # noqa: F401 # type: ignore[unresolved-import]
26
+ import app.frontend.oauth as _app_oauth # type: ignore[unresolved-import] # noqa: F401
27
+ import app.frontend.routes as _app_routes # type: ignore[unresolved-import] # noqa: F401
26
28
 
27
29
  # Register OAuth routes after providers are registered
28
30
  from .routes.auth import register_oauth_routes
29
31
 
30
32
  register_oauth_routes()
31
- except (ImportError, ModuleNotFoundError):
33
+ except ModuleNotFoundError:
34
+ # Silent pass for missing app.frontend.oauth or app.frontend.routes modules (expected in some projects)
32
35
  pass
36
+ except ImportError as e:
37
+ # Log warning for any import error (including syntax errors, missing dependencies, etc.)
38
+ logger.warning(
39
+ f"Failed to import app.frontend.oauth or app.frontend.routes: {e}. OAuth and custom routes will not be available."
40
+ )
41
+
42
+ try:
43
+ from app.frontend.middleware import (
44
+ middlewares as app_middlewares, # type: ignore[unresolved-import]
45
+ )
46
+
47
+ middlewares.extend(app_middlewares)
48
+ except ModuleNotFoundError:
49
+ pass
50
+ except ImportError as e:
51
+ # Log warning for any import error (including syntax errors, missing dependencies, etc.)
52
+ logger.warning(
53
+ f"Failed to import app.frontend.middleware: {e}. Additional middlewares will not be available."
54
+ )
33
55
 
34
56
 
35
57
  dependencies: list[Any] = [
@@ -38,7 +60,7 @@ dependencies: list[Any] = [
38
60
 
39
61
  app = FastAPI(
40
62
  debug=ctx.DEBUG,
41
- lifespan=lifespan,
63
+ lifespan=lifespan_module.lifespan,
42
64
  docs_url=None,
43
65
  redoc_url=None,
44
66
  openapi_url=None,
@@ -95,5 +117,6 @@ def default_index(request: Request) -> HTMLResponse:
95
117
  return render_template("index.html.jinja", request)
96
118
 
97
119
 
120
+ app.include_router(debug.auth_router)
98
121
  app.include_router(debug.router)
99
122
  app.include_router(health.router)
@@ -1,4 +1,5 @@
1
1
  from contextlib import asynccontextmanager
2
+ from typing import AsyncGenerator
2
3
 
3
4
  from fastapi import FastAPI
4
5
 
@@ -10,7 +11,7 @@ from .hotreload import hotreload
10
11
 
11
12
 
12
13
  @asynccontextmanager
13
- async def base_lifespan(app: FastAPI):
14
+ async def base_lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
14
15
  logger.info("Vibetuner frontend starting")
15
16
  if ctx.DEBUG:
16
17
  await hotreload.startup()
@@ -27,5 +28,10 @@ async def base_lifespan(app: FastAPI):
27
28
 
28
29
  try:
29
30
  from app.frontend.lifespan import lifespan # ty: ignore
30
- except ImportError:
31
+ except ModuleNotFoundError:
32
+ # Silent pass for missing app.frontend.lifespan module (expected in some projects)
33
+ lifespan = base_lifespan
34
+ except ImportError as e:
35
+ # Log warning for any import error (including syntax errors, missing dependencies, etc.)
36
+ logger.warning(f"Failed to import app.frontend.lifespan: {e}. Using base lifespan.")
31
37
  lifespan = base_lifespan
@@ -126,7 +126,6 @@ class AuthBackend(AuthenticationBackend):
126
126
  return None
127
127
 
128
128
 
129
- # Until this line
130
129
  middlewares: list[Middleware] = [
131
130
  Middleware(TrustedHostMiddleware),
132
131
  Middleware(ForwardedProtocolMiddleware),
@@ -145,7 +144,4 @@ middlewares: list[Middleware] = [
145
144
  ),
146
145
  Middleware(AdjustLangCookieMiddleware),
147
146
  Middleware(AuthenticationMiddleware, backend=AuthBackend()),
148
- # Add your middleware below this line
149
147
  ]
150
-
151
- # EOF
@@ -3,13 +3,13 @@ from fastapi import (
3
3
  Depends,
4
4
  HTTPException,
5
5
  Request,
6
- Response,
7
6
  )
8
7
  from fastapi.responses import (
9
8
  HTMLResponse,
10
9
  RedirectResponse,
11
10
  )
12
11
 
12
+ from vibetuner.config import settings
13
13
  from vibetuner.context import ctx
14
14
  from vibetuner.models import UserModel
15
15
  from vibetuner.models.registry import get_all_models
@@ -18,32 +18,41 @@ from ..deps import MAGIC_COOKIE_NAME
18
18
  from ..templates import render_template
19
19
 
20
20
 
21
- def check_debug_access(request: Request, 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
 
@@ -7,7 +7,7 @@ from starlette.responses import HTMLResponse
7
7
  from starlette_babel import gettext_lazy as _, gettext_lazy as ngettext
8
8
  from starlette_babel.contrib.jinja import configure_jinja_env
9
9
 
10
- from vibetuner.context import Context
10
+ from vibetuner.context import ctx as data_ctx
11
11
  from vibetuner.paths import frontend_templates
12
12
  from vibetuner.templates import render_static_template
13
13
  from vibetuner.time import age_in_timedelta
@@ -17,9 +17,36 @@ from .hotreload import hotreload
17
17
 
18
18
  __all__ = [
19
19
  "render_static_template",
20
+ "register_filter",
20
21
  ]
21
22
 
22
- data_ctx = Context()
23
+
24
+ _filter_registry: dict[str, Any] = {}
25
+
26
+
27
+ def register_filter(name: str | None = None):
28
+ """Decorator to register a custom Jinja2 filter.
29
+
30
+ Args:
31
+ name: Optional custom name for the filter. If not provided,
32
+ uses the function name.
33
+
34
+ Usage:
35
+ @register_filter()
36
+ def my_filter(value):
37
+ return value.upper()
38
+
39
+ @register_filter("custom_name")
40
+ def another_filter(value):
41
+ return value.lower()
42
+ """
43
+
44
+ def decorator(func):
45
+ filter_name = name or func.__name__
46
+ _filter_registry[filter_name] = func
47
+ return func
48
+
49
+ return decorator
23
50
 
24
51
 
25
52
  def timeago(dt):
@@ -173,4 +200,22 @@ jinja_env.filters["format_datetime"] = format_datetime
173
200
  jinja_env.filters["format_duration"] = format_duration
174
201
  jinja_env.filters["duration"] = format_duration
175
202
 
203
+ # Import user-defined filters to trigger registration
204
+ try:
205
+ import app.frontend.templates as _app_templates # type: ignore[import-not-found] # noqa: F401
206
+ except ModuleNotFoundError:
207
+ # Silent pass - templates module is optional
208
+ pass
209
+ except ImportError as e:
210
+ from vibetuner.logging import logger
211
+
212
+ logger.warning(
213
+ f"Failed to import app.frontend.templates: {e}. Custom filters will not be available."
214
+ )
215
+
216
+ # Apply all registered custom filters
217
+ for filter_name, filter_func in _filter_registry.items():
218
+ jinja_env.filters[filter_name] = filter_func
219
+
220
+ # Configure Jinja environment after all filters are registered
176
221
  configure_jinja_env(jinja_env)
vibetuner/mongo.py CHANGED
@@ -1,13 +1,28 @@
1
+ from importlib import import_module
2
+
1
3
  from beanie import init_beanie
2
4
  from pymongo import AsyncMongoClient
3
5
 
4
6
  from vibetuner.config import settings
7
+ from vibetuner.logging import logger
5
8
  from vibetuner.models.registry import get_all_models
6
9
 
7
10
 
8
11
  async def init_models() -> None:
9
12
  """Initialize MongoDB connection and register all Beanie models."""
10
13
 
14
+ # Try to import user models to trigger their registration
15
+ try:
16
+ import_module("app.models")
17
+ except ModuleNotFoundError:
18
+ # Silent pass for missing app.models module (expected in some projects)
19
+ pass
20
+ except ImportError as e:
21
+ # Log warning for any import error (including syntax errors, missing dependencies, etc.)
22
+ logger.warning(
23
+ f"Failed to import app.models: {e}. User models will not be registered."
24
+ )
25
+
11
26
  client: AsyncMongoClient = AsyncMongoClient(
12
27
  host=str(settings.mongodb_url),
13
28
  compressors=["zstd"],
vibetuner/paths.py CHANGED
@@ -5,6 +5,8 @@ from typing import Self
5
5
  from pydantic import computed_field, model_validator
6
6
  from pydantic_settings import BaseSettings, SettingsConfigDict
7
7
 
8
+ from vibetuner.logging import logger
9
+
8
10
 
9
11
  # Package-relative paths (for bundled templates in the vibetuner package)
10
12
  _package_files = files("vibetuner")
@@ -22,6 +24,38 @@ def _get_package_templates_path() -> Path:
22
24
  ) from None
23
25
 
24
26
 
27
+ def create_core_templates_symlink(target: Path) -> None:
28
+ """Create or update a 'core' symlink pointing to the package templates directory."""
29
+
30
+ try:
31
+ source = _get_package_templates_path().resolve()
32
+ except RuntimeError as e:
33
+ logger.error(f"Cannot create symlink: {e}")
34
+ return
35
+
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
47
+ if not target.exists():
48
+ target.symlink_to(source, target_is_directory=True)
49
+ logger.info(f"Created symlink '{target}' → '{source}'")
50
+ return
51
+
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
+ )
57
+
58
+
25
59
  # Package templates always available
26
60
  package_templates = _get_package_templates_path()
27
61
  core_templates = package_templates # Alias for backwards compatibility
@@ -158,10 +192,6 @@ class PathSettings(BaseSettings):
158
192
  paths.append(package_templates / "markdown")
159
193
  return paths
160
194
 
161
- def set_root(self, project_root: Path) -> None:
162
- """Explicitly set project root (overrides auto-detection)."""
163
- self.root = project_root
164
-
165
195
  def to_template_path_list(self, path: Path) -> list[Path]:
166
196
  """Convert path to list with fallback."""
167
197
  return [path, path / self.fallback_path]
@@ -191,12 +221,6 @@ class PathSettings(BaseSettings):
191
221
  _settings = PathSettings()
192
222
 
193
223
 
194
- # Backwards-compatible module-level API
195
- def set_project_root(project_root: Path) -> None:
196
- """Set the project root directory explicitly."""
197
- _settings.set_root(project_root)
198
-
199
-
200
224
  def to_template_path_list(path: Path) -> list[Path]:
201
225
  """Convert path to list with fallback."""
202
226
  return _settings.to_template_path_list(path)