Files

196 lines
7.3 KiB
Python

import inspect
import os
from functools import wraps
from typing import Callable, TypeVar, ParamSpec, Optional, Union, overload
import fastapi
from chameleon import PageTemplateLoader, PageTemplate
from fastapi_chameleon.exceptions import (
FastAPIChameleonException,
FastAPIChameleonGenericException,
FastAPIChameleonNotFoundException,
)
__templates: Optional[PageTemplateLoader] = None
template_path: Optional[str] = None
P = ParamSpec('P')
R = TypeVar('R')
# Overload for when the decorator is used with arguments.
@overload
def template(
template_file: Optional[Union[Callable[..., R], str]] = None,
mimetype: str = 'text/html'
) -> Callable[[Callable[P, R]], Callable[P, R]]:
...
# Overload for when the decorator is used without arguments.
@overload
def template(
f: Callable[P, R]
) -> Callable[P, R]:
...
def global_init(template_folder: str, auto_reload=False, cache_init=True):
global __templates, template_path
if __templates and cache_init:
return
if not template_folder:
msg = 'The template_folder must be specified.'
raise FastAPIChameleonException(msg)
if not os.path.isdir(template_folder):
msg = f"The specified template folder must be a folder, it's not: {template_folder}"
raise FastAPIChameleonException(msg)
template_path = template_folder
__templates = PageTemplateLoader(template_folder, auto_reload=auto_reload)
def clear():
global __templates, template_path
__templates = None
template_path = None
def render(template_file: str, **template_data: dict) -> str:
if not __templates:
raise FastAPIChameleonException('You must call global_init() before rendering templates.')
page: PageTemplate = __templates[template_file]
return page.render(encoding='utf-8', **template_data)
def response(template_file: str, mimetype='text/html', status_code=200, **template_data) -> fastapi.Response:
html = render(template_file, **template_data)
return fastapi.Response(content=html, media_type=mimetype, status_code=status_code)
def template(template_file: Optional[Union[Callable[..., R], str]] = None, mimetype: str = 'text/html'):
"""
Decorate a FastAPI view method to render an HTML response.
:param str template_file: Optional, the Chameleon template file (path relative to template folder, *.pt).
:param str mimetype: The mimetype response (defaults to text/html).
:return: Decorator to be consumed by FastAPI
"""
if callable(template_file):
# If the first parameter is callable, the decorator is being used without arguments.
func = template_file
template_file = None
return _decorate(func, template_file, mimetype)
else:
# If template_file is not callable, return a lambda that will wrap the function.
return lambda f: _decorate(f, template_file, mimetype)
def _decorate(f: Callable[P, R], template_file: Optional[str], mimetype: str) -> Callable[P, R]:
"""
Internal decorator function that wraps the FastAPI view function to handle rendering.
It supports both synchronous and asynchronous view methods.
:param f: The original FastAPI view function.
:param template_file: The optional template file path. If None, a default naming scheme is applied.
:param mimetype: The mimetype for the response.
:return: The wrapped function with additional rendering logic.
"""
global template_path
# Ensure the global template_path is initialized; default to 'templates' if not set.
if not template_path:
template_path = 'templates'
# Optionally, raise an exception if template_path must be initialized beforehand:
# raise FastAPIChameleonException("Cannot continue: fastapi_chameleon.global_init() has not been called.")
# If no template file was provided, derive it from the function's module and name.
if not template_file:
# Use the default naming scheme: template_folder/module_name/function_name.pt
module = f.__module__
# Use only the last part of the module name if it's a dotted path.
if '.' in module:
module = module.split('.')[-1]
view = f.__name__
# Default to an HTML template
template_file = f'{module}/{view}.html'
# If the .html file does not exist, fallback to a .pt template.
if not os.path.exists(os.path.join(template_path, template_file)):
template_file = f'{module}/{view}.pt'
@wraps(f)
def sync_view_method(*args: P.args, **kwargs: P.kwargs) -> R:
"""
Synchronous wrapper for the view function.
Calls the view, renders the response using the specified template,
and handles exceptions by rendering error templates.
"""
try:
response_val = f(*args, **kwargs)
return __render_response(template_file, response_val, mimetype)
except FastAPIChameleonNotFoundException as nfe:
return __render_response(nfe.template_file, {}, 'text/html', 404)
except FastAPIChameleonGenericException as nfe:
template_data = nfe.template_data if nfe.template_data is not None else {}
return __render_response(nfe.template_file, template_data, 'text/html', nfe.status_code)
@wraps(f)
async def async_view_method(*args: P.args, **kwargs: P.kwargs) -> R:
"""
Asynchronous wrapper for the view function.
Awaits the view, renders the response using the specified template,
and handles exceptions by rendering error templates.
"""
try:
response_val = await f(*args, **kwargs)
return __render_response(template_file, response_val, mimetype)
except FastAPIChameleonNotFoundException as nfe:
return __render_response(nfe.template_file, {}, 'text/html', 404)
except FastAPIChameleonGenericException as nfe:
template_data = nfe.template_data if nfe.template_data is not None else {}
return __render_response(nfe.template_file, template_data, 'text/html', nfe.status_code)
# Return the appropriate wrapper based on whether the original function is a coroutine.
if inspect.iscoroutinefunction(f):
return async_view_method
else:
return sync_view_method
def __render_response(template_file, response_val, mimetype, status_code: int = 200) -> fastapi.Response:
# source skip: assign-if-exp
if isinstance(response_val, fastapi.Response):
return response_val
if template_file and not isinstance(response_val, dict):
msg = f'Invalid return type {type(response_val)}, we expected a dict or fastapi.Response as the return value.'
raise FastAPIChameleonException(msg)
model = response_val
html = render(template_file, **model)
return fastapi.Response(content=html, media_type=mimetype, status_code=status_code)
def not_found(four04template_file: str = 'errors/404.pt'):
msg = 'The URL resulted in a 404 response.'
if four04template_file and four04template_file.strip():
raise FastAPIChameleonNotFoundException(msg, four04template_file)
else:
raise FastAPIChameleonNotFoundException(msg)
def generic_error(template_file: str, status_code: int, template_data: Optional[dict] = None):
msg = 'The URL resulted in an error.'
raise FastAPIChameleonGenericException(template_file, status_code, msg, template_data=template_data)