├── .github └── FUNDING.yml ├── .gitignore ├── .idea ├── fastapi-chameleon.iml ├── inspectionProfiles │ ├── Project_Default.xml │ └── profiles_settings.xml ├── misc.xml ├── modules.xml └── vcs.xml ├── LICENSE ├── README.md ├── example ├── example_app.py ├── static │ └── site.css └── templates │ ├── async.pt │ └── index.pt ├── fastapi_chameleon ├── __init__.py ├── engine.py └── exceptions.py ├── pyproject.toml ├── requirements-dev.txt ├── requirements.txt ├── ruff.toml ├── tests ├── conftest.py ├── templates │ ├── errors │ │ ├── 404.pt │ │ ├── error_with_data.pt │ │ └── other_error_page.pt │ ├── home │ │ └── index.pt │ └── test_render │ │ ├── details.html │ │ └── index.pt ├── test_generic_error.py ├── test_generic_error_data.py ├── test_init.py ├── test_not_found.py └── test_render.py └── tox.ini /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [mikeckennedy] 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | .idea 131 | -------------------------------------------------------------------------------- /.idea/fastapi-chameleon.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 15 | 16 | 22 | 23 | 24 | 26 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 78 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Michael Kennedy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fastapi-chameleon 2 | 3 | Adds integration of the Chameleon template language to FastAPI. If you are interested in Jinja instead, see the sister project: [github.com/AGeekInside/fastapi-jinja](https://github.com/AGeekInside/fastapi-jinja). 4 | 5 | ## Installation 6 | 7 | Simply `pip install fastapi_chameleon`. 8 | 9 | ## Usage 10 | 11 | This is easy to use. Just create a folder within your web app to hold the templates such as: 12 | 13 | ``` 14 | ├── main.py 15 | ├── views.py 16 | │ 17 | ├── templates 18 | │ ├── home 19 | │ │ └── index.pt 20 | │ └── shared 21 | │ └── layout.pt 22 | 23 | ``` 24 | 25 | In the app startup, tell the library about the folder you wish to use: 26 | 27 | ```python 28 | import os 29 | from pathlib import Path 30 | import fastapi_chameleon 31 | 32 | dev_mode = True 33 | 34 | BASE_DIR = Path(__file__).resolve().parent 35 | template_folder = str(BASE_DIR / 'templates') 36 | fastapi_chameleon.global_init(template_folder, auto_reload=dev_mode) 37 | ``` 38 | 39 | Then just decorate the FastAPI view methods (works on sync and async methods): 40 | 41 | ```python 42 | @router.post('/') 43 | @fastapi_chameleon.template('home/index.pt') 44 | async def home_post(request: Request): 45 | form = await request.form() 46 | vm = PersonViewModel(**form) 47 | 48 | return vm.dict() # {'first':'Michael', 'last':'Kennedy', ...} 49 | 50 | ``` 51 | 52 | The view method should return a `dict` to be passed as variables/values to the template. 53 | 54 | If a `fastapi.Response` is returned, the template is skipped and the response along with status_code and 55 | other values is directly passed through. This is common for redirects and error responses not meant 56 | for this page template. 57 | 58 | ## Friendly 404s and errors 59 | 60 | A common technique for user-friendly sites is to use a 61 | [custom HTML page for 404 responses](http://www.instantshift.com/2019/10/16/user-friendly-404-pages/). 62 | This is especially important in FastAPI because FastAPI returns a 404 response + JSON by default. 63 | This library has support for friendly 404 pages using the `fastapi_chameleon.not_found()` function. 64 | 65 | Here's an example: 66 | 67 | ```python 68 | @router.get('/catalog/item/{item_id}') 69 | @fastapi_chameleon.template('catalog/item.pt') 70 | async def item(item_id: int): 71 | item = service.get_item_by_id(item_id) 72 | if not item: 73 | fastapi_chameleon.not_found() 74 | 75 | return item.dict() 76 | ``` 77 | 78 | This will render a 404 response with using the template file `templates/errors/404.pt`. 79 | You can specify another template to use for the response, but it's not required. 80 | 81 | If you need to return errors other than `Not Found` (status code `404`), you can use a more 82 | generic function: `fastapi_chameleon.generic_error(template_file: str, status_code: int)`. 83 | This function will allow you to return different status codes. It's generic, thus you'll have 84 | to pass a path to your error template file as well as a status code you want the user to get 85 | in response. For example: 86 | 87 | ```python 88 | @router.get('/catalog/item/{item_id}') 89 | @fastapi_chameleon.template('catalog/item.pt') 90 | async def item(item_id: int): 91 | item = service.get_item_by_id(item_id) 92 | if not item: 93 | fastapi_chameleon.generic_error('errors/unauthorized.pt', 94 | fastapi.status.HTTP_401_UNAUTHORIZED) 95 | 96 | return item.dict() 97 | ``` 98 | -------------------------------------------------------------------------------- /example/example_app.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pathlib import Path 3 | 4 | import fastapi 5 | import uvicorn 6 | 7 | import fastapi_chameleon 8 | 9 | app = fastapi.FastAPI() 10 | 11 | 12 | @app.get("/") 13 | @fastapi_chameleon.template('index.pt') 14 | def hello_world(): 15 | return {'message': "Let's go Chameleon and FastAPI!"} 16 | 17 | 18 | @app.get('/async') 19 | @fastapi_chameleon.template('async.pt') 20 | async def async_world(): 21 | await asyncio.sleep(.01) 22 | return {'message': "Let's go async Chameleon and FastAPI!"} 23 | 24 | 25 | def add_chameleon(): 26 | dev_mode = True 27 | 28 | BASE_DIR = Path(__file__).resolve().parent 29 | template_folder = (BASE_DIR / 'templates').as_posix() 30 | fastapi_chameleon.global_init(template_folder, auto_reload=dev_mode) 31 | 32 | 33 | def main(): 34 | add_chameleon() 35 | uvicorn.run(app, host='127.0.0.1', port=8000) 36 | 37 | 38 | if __name__ == '__main__': 39 | main() 40 | -------------------------------------------------------------------------------- /example/static/site.css: -------------------------------------------------------------------------------- 1 | body { 2 | padding: 20px; 3 | background-color: #fafafa; 4 | } 5 | 6 | h1, p { 7 | text-align: center; 8 | } -------------------------------------------------------------------------------- /example/templates/async.pt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Title 6 | 7 | 8 | 9 |

Hello async world

10 |

Your async message is ${message}

11 | 12 | -------------------------------------------------------------------------------- /example/templates/index.pt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Title 6 | 7 | 8 | 9 |

Hello world

10 |

Your message is ${message} 11 | See the async example too.

12 | 13 | -------------------------------------------------------------------------------- /fastapi_chameleon/__init__.py: -------------------------------------------------------------------------------- 1 | """fastapi-chameleon - Adds integration of the Chameleon template language to FastAPI.""" 2 | 3 | __version__ = '0.1.17' 4 | __author__ = 'Michael Kennedy ' 5 | __all__ = ['template', 'global_init', 'not_found', 'response', 'generic_error', ] 6 | 7 | from .engine import generic_error 8 | from .engine import global_init 9 | from .engine import not_found 10 | from .engine import response 11 | from .engine import template 12 | -------------------------------------------------------------------------------- /fastapi_chameleon/engine.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import os 3 | from functools import wraps 4 | from typing import Callable, TypeVar, ParamSpec, Optional, Union, overload 5 | 6 | import fastapi 7 | from chameleon import PageTemplateLoader, PageTemplate 8 | 9 | from fastapi_chameleon.exceptions import ( 10 | FastAPIChameleonException, 11 | FastAPIChameleonGenericException, 12 | FastAPIChameleonNotFoundException, 13 | ) 14 | 15 | __templates: Optional[PageTemplateLoader] = None 16 | template_path: Optional[str] = None 17 | 18 | P = ParamSpec('P') 19 | R = TypeVar('R') 20 | 21 | 22 | # Overload for when the decorator is used with arguments. 23 | @overload 24 | def template( 25 | template_file: Optional[Union[Callable[..., R], str]] = None, 26 | mimetype: str = 'text/html' 27 | ) -> Callable[[Callable[P, R]], Callable[P, R]]: 28 | ... 29 | 30 | 31 | # Overload for when the decorator is used without arguments. 32 | @overload 33 | def template( 34 | f: Callable[P, R] 35 | ) -> Callable[P, R]: 36 | ... 37 | 38 | 39 | def global_init(template_folder: str, auto_reload=False, cache_init=True): 40 | global __templates, template_path 41 | 42 | if __templates and cache_init: 43 | return 44 | 45 | if not template_folder: 46 | msg = 'The template_folder must be specified.' 47 | raise FastAPIChameleonException(msg) 48 | 49 | if not os.path.isdir(template_folder): 50 | msg = f"The specified template folder must be a folder, it's not: {template_folder}" 51 | raise FastAPIChameleonException(msg) 52 | 53 | template_path = template_folder 54 | __templates = PageTemplateLoader(template_folder, auto_reload=auto_reload) 55 | 56 | 57 | def clear(): 58 | global __templates, template_path 59 | __templates = None 60 | template_path = None 61 | 62 | 63 | def render(template_file: str, **template_data: dict) -> str: 64 | if not __templates: 65 | raise FastAPIChameleonException('You must call global_init() before rendering templates.') 66 | 67 | page: PageTemplate = __templates[template_file] 68 | return page.render(encoding='utf-8', **template_data) 69 | 70 | 71 | def response(template_file: str, mimetype='text/html', status_code=200, **template_data) -> fastapi.Response: 72 | html = render(template_file, **template_data) 73 | return fastapi.Response(content=html, media_type=mimetype, status_code=status_code) 74 | 75 | 76 | def template(template_file: Optional[Union[Callable[..., R], str]] = None, mimetype: str = 'text/html'): 77 | """ 78 | Decorate a FastAPI view method to render an HTML response. 79 | 80 | :param str template_file: Optional, the Chameleon template file (path relative to template folder, *.pt). 81 | :param str mimetype: The mimetype response (defaults to text/html). 82 | :return: Decorator to be consumed by FastAPI 83 | """ 84 | if callable(template_file): 85 | # If the first parameter is callable, the decorator is being used without arguments. 86 | func = template_file 87 | template_file = None 88 | return _decorate(func, template_file, mimetype) 89 | else: 90 | # If template_file is not callable, return a lambda that will wrap the function. 91 | return lambda f: _decorate(f, template_file, mimetype) 92 | 93 | 94 | def _decorate(f: Callable[P, R], template_file: Optional[str], mimetype: str) -> Callable[P, R]: 95 | """ 96 | Internal decorator function that wraps the FastAPI view function to handle rendering. 97 | It supports both synchronous and asynchronous view methods. 98 | 99 | :param f: The original FastAPI view function. 100 | :param template_file: The optional template file path. If None, a default naming scheme is applied. 101 | :param mimetype: The mimetype for the response. 102 | :return: The wrapped function with additional rendering logic. 103 | """ 104 | global template_path 105 | 106 | # Ensure the global template_path is initialized; default to 'templates' if not set. 107 | if not template_path: 108 | template_path = 'templates' 109 | # Optionally, raise an exception if template_path must be initialized beforehand: 110 | # raise FastAPIChameleonException("Cannot continue: fastapi_chameleon.global_init() has not been called.") 111 | 112 | # If no template file was provided, derive it from the function's module and name. 113 | if not template_file: 114 | # Use the default naming scheme: template_folder/module_name/function_name.pt 115 | module = f.__module__ 116 | 117 | # Use only the last part of the module name if it's a dotted path. 118 | if '.' in module: 119 | module = module.split('.')[-1] 120 | view = f.__name__ 121 | 122 | # Default to an HTML template 123 | template_file = f'{module}/{view}.html' 124 | 125 | # If the .html file does not exist, fallback to a .pt template. 126 | if not os.path.exists(os.path.join(template_path, template_file)): 127 | template_file = f'{module}/{view}.pt' 128 | 129 | @wraps(f) 130 | def sync_view_method(*args: P.args, **kwargs: P.kwargs) -> R: 131 | """ 132 | Synchronous wrapper for the view function. 133 | Calls the view, renders the response using the specified template, 134 | and handles exceptions by rendering error templates. 135 | """ 136 | try: 137 | response_val = f(*args, **kwargs) 138 | return __render_response(template_file, response_val, mimetype) 139 | except FastAPIChameleonNotFoundException as nfe: 140 | return __render_response(nfe.template_file, {}, 'text/html', 404) 141 | except FastAPIChameleonGenericException as nfe: 142 | template_data = nfe.template_data if nfe.template_data is not None else {} 143 | return __render_response(nfe.template_file, template_data, 'text/html', nfe.status_code) 144 | 145 | @wraps(f) 146 | async def async_view_method(*args: P.args, **kwargs: P.kwargs) -> R: 147 | """ 148 | Asynchronous wrapper for the view function. 149 | Awaits the view, renders the response using the specified template, 150 | and handles exceptions by rendering error templates. 151 | """ 152 | try: 153 | response_val = await f(*args, **kwargs) 154 | return __render_response(template_file, response_val, mimetype) 155 | except FastAPIChameleonNotFoundException as nfe: 156 | return __render_response(nfe.template_file, {}, 'text/html', 404) 157 | except FastAPIChameleonGenericException as nfe: 158 | template_data = nfe.template_data if nfe.template_data is not None else {} 159 | return __render_response(nfe.template_file, template_data, 'text/html', nfe.status_code) 160 | 161 | # Return the appropriate wrapper based on whether the original function is a coroutine. 162 | if inspect.iscoroutinefunction(f): 163 | return async_view_method 164 | else: 165 | return sync_view_method 166 | 167 | 168 | def __render_response(template_file, response_val, mimetype, status_code: int = 200) -> fastapi.Response: 169 | # source skip: assign-if-exp 170 | if isinstance(response_val, fastapi.Response): 171 | return response_val 172 | 173 | if template_file and not isinstance(response_val, dict): 174 | msg = f'Invalid return type {type(response_val)}, we expected a dict or fastapi.Response as the return value.' 175 | raise FastAPIChameleonException(msg) 176 | 177 | model = response_val 178 | 179 | html = render(template_file, **model) 180 | return fastapi.Response(content=html, media_type=mimetype, status_code=status_code) 181 | 182 | 183 | def not_found(four04template_file: str = 'errors/404.pt'): 184 | msg = 'The URL resulted in a 404 response.' 185 | 186 | if four04template_file and four04template_file.strip(): 187 | raise FastAPIChameleonNotFoundException(msg, four04template_file) 188 | else: 189 | raise FastAPIChameleonNotFoundException(msg) 190 | 191 | 192 | def generic_error(template_file: str, status_code: int, template_data: Optional[dict] = None): 193 | msg = 'The URL resulted in an error.' 194 | 195 | raise FastAPIChameleonGenericException(template_file, status_code, msg, template_data=template_data) 196 | -------------------------------------------------------------------------------- /fastapi_chameleon/exceptions.py: -------------------------------------------------------------------------------- 1 | from typing import Optional 2 | 3 | 4 | class FastAPIChameleonException(Exception): 5 | pass 6 | 7 | 8 | class FastAPIChameleonNotFoundException(FastAPIChameleonException): 9 | def __init__(self, message: Optional[str] = None, four04template_file: str = 'errors/404.pt'): 10 | super().__init__(message) 11 | 12 | self.template_file: str = four04template_file 13 | self.message: Optional[str] = message 14 | 15 | 16 | class FastAPIChameleonGenericException(FastAPIChameleonException): 17 | def __init__(self, template_file: str, status_code: int, 18 | message: Optional[str] = None, template_data: Optional[dict] = None): 19 | super().__init__(message) 20 | 21 | self.template_file: str = template_file 22 | self.status_code: int = status_code 23 | self.message: Optional[str] = message 24 | self.template_data: Optional[dict] = template_data 25 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "fastapi_chameleon" 3 | version = "0.1.17" 4 | description = "Adds integration of the Chameleon template language to FastAPI." 5 | readme = { file = "README.md", content-type = "text/markdown" } 6 | requires-python = ">=3.10" 7 | license = { text = "MIT" } 8 | authors = [ 9 | { name = "Michael Kennedy", email = "michael@talkpython.fm" } 10 | ] 11 | keywords = ["FastAPI", "Chameleon", "template", "integration"] 12 | classifiers = [ 13 | "Development Status :: 5 - Production/Stable", 14 | "License :: OSI Approved :: MIT License", 15 | "Programming Language :: Python", 16 | "Programming Language :: Python :: 3.10", 17 | "Programming Language :: Python :: 3.11", 18 | "Programming Language :: Python :: 3.12", 19 | "Programming Language :: Python :: 3.13", 20 | "Programming Language :: Python :: 3.14", 21 | ] 22 | dependencies = [ 23 | "fastapi", 24 | "chameleon" 25 | ] 26 | 27 | [project.urls] 28 | "Homepage" = "https://github.com/mikeckennedy/fastapi-chameleon" 29 | 30 | [tool.setuptools] 31 | packages = ["fastapi_chameleon"] 32 | 33 | [build-system] 34 | requires = ["hatchling>=1.21.0", "hatch-vcs>=0.3.0"] 35 | build-backend = "hatchling.build" 36 | 37 | 38 | [tool.hatch.build.targets.sdist] 39 | exclude = [ 40 | "/.github", 41 | "/tests", 42 | "/example_app", 43 | "settings.json", 44 | ] 45 | 46 | [tool.hatch.build.targets.wheel] 47 | packages = ["fastapi_chameleon"] 48 | exclude = [ 49 | "/.github", 50 | "/tests", 51 | "/example", 52 | "/example_client", 53 | "settings.json", 54 | "ruff.toml", 55 | ] -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | -r requirements.txt 2 | 3 | pytest 4 | pytest-clarity 5 | twine 6 | hatchling 7 | hatch-vcs>=0.3.0 8 | uvicorn 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | fastapi 2 | Chameleon 3 | -------------------------------------------------------------------------------- /ruff.toml: -------------------------------------------------------------------------------- 1 | # [ruff] 2 | line-length = 120 3 | format.quote-style = "single" 4 | 5 | # Enable Pyflakes `E` and `F` codes by default. 6 | lint.select = ["E", "F"] 7 | lint.ignore = [] 8 | 9 | # Exclude a variety of commonly ignored directories. 10 | exclude = [ 11 | ".bzr", 12 | ".direnv", 13 | ".eggs", 14 | ".git", 15 | ".hg", 16 | ".mypy_cache", 17 | ".nox", 18 | ".pants.d", 19 | ".ruff_cache", 20 | ".svn", 21 | ".tox", 22 | "__pypackages__", 23 | "_build", 24 | "buck-out", 25 | "build", 26 | "dist", 27 | "node_modules", 28 | ".env", 29 | ".venv", 30 | "venv", 31 | ] 32 | lint.per-file-ignores = {} 33 | 34 | # Allow unused variables when underscore-prefixed. 35 | # dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" 36 | 37 | # Assume Python 3.13. 38 | target-version = "py313" 39 | 40 | #[tool.ruff.mccabe] 41 | ## Unlike Flake8, default to a complexity level of 10. 42 | lint.mccabe.max-complexity = 10 43 | -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | import fastapi_chameleon as fc 6 | 7 | 8 | @pytest.fixture 9 | def test_templates_path(pytestconfig): 10 | return Path(pytestconfig.rootdir, 'tests', 'templates') 11 | 12 | 13 | @pytest.fixture 14 | def setup_global_template(test_templates_path): 15 | fc.global_init(str(test_templates_path)) 16 | yield 17 | # Clear paths so as to no affect future tests 18 | fc.engine.clear() 19 | -------------------------------------------------------------------------------- /tests/templates/errors/404.pt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Page Not Found 6 | 7 | 8 |

This is a pretty 404 page.

9 | 10 | -------------------------------------------------------------------------------- /tests/templates/errors/error_with_data.pt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |

5 | 6 | 7 | -------------------------------------------------------------------------------- /tests/templates/errors/other_error_page.pt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Page Not Found 6 | 7 | 8 |

Another pretty 404 page.

9 | 10 | -------------------------------------------------------------------------------- /tests/templates/home/index.pt: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Hello, ${'world'}!

4 | 5 | 6 | 9 | 10 |
7 | ${row.capitalize()} ${col} 8 |
11 | 12 | -------------------------------------------------------------------------------- /tests/templates/test_render/details.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Hello default ${world}!

4 | 5 | 6 | 9 | 10 |
7 | ${row.capitalize()} ${col} 8 |
11 | 12 | -------------------------------------------------------------------------------- /tests/templates/test_render/index.pt: -------------------------------------------------------------------------------- 1 | 2 | 3 |

Hello default ${world}!

4 | 5 | 6 | 9 | 10 |
7 | ${row.capitalize()} ${col} 8 |
11 | 12 | -------------------------------------------------------------------------------- /tests/test_generic_error.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import fastapi 4 | import pytest 5 | 6 | import fastapi_chameleon 7 | import fastapi_chameleon as fc 8 | 9 | 10 | # setup_global_template - needed as pytest mix-in. 11 | # noinspection PyUnusedLocal 12 | @pytest.mark.parametrize( 13 | ('status_code', 'template_file', 'expected_h1_in_body'), 14 | [ 15 | (fastapi.status.HTTP_400_BAD_REQUEST, 'errors/404.pt', b'

This is a pretty 404 page.

'), 16 | (fastapi.status.HTTP_401_UNAUTHORIZED, 'errors/other_error_page.pt', b'

Another pretty 404 page.

'), 17 | (fastapi.status.HTTP_403_FORBIDDEN, 'errors/404.pt', b'

This is a pretty 404 page.

'), 18 | (fastapi.status.HTTP_404_NOT_FOUND, 'errors/other_error_page.pt', b'

Another pretty 404 page.

'), 19 | (fastapi.status.HTTP_405_METHOD_NOT_ALLOWED, 'errors/404.pt', b'

This is a pretty 404 page.

'), 20 | (fastapi.status.HTTP_406_NOT_ACCEPTABLE, 'errors/other_error_page.pt', b'

Another pretty 404 page.

'), 21 | ( 22 | fastapi.status.HTTP_407_PROXY_AUTHENTICATION_REQUIRED, 23 | 'errors/404.pt', 24 | b'

This is a pretty 404 page.

', 25 | ), 26 | (fastapi.status.HTTP_408_REQUEST_TIMEOUT, 'errors/other_error_page.pt', b'

Another pretty 404 page.

'), 27 | ], 28 | ) 29 | def test_friendly_403_sync_method(setup_global_template, status_code, template_file, expected_h1_in_body): 30 | @fc.template('home/index.pt') 31 | def view_method(a, b, c): 32 | fastapi_chameleon.generic_error(template_file, status_code) 33 | return {'a': a, 'b': b, 'c': c} 34 | 35 | resp = view_method(1, 2, 3) 36 | assert isinstance(resp, fastapi.Response) 37 | assert resp.status_code == status_code 38 | assert expected_h1_in_body in resp.body 39 | 40 | 41 | # setup_global_template - needed as pytest mix-in. 42 | # noinspection PyUnusedLocal 43 | @pytest.mark.parametrize( 44 | ('status_code', 'template_file', 'expected_h1_in_body'), 45 | [ 46 | (fastapi.status.HTTP_400_BAD_REQUEST, 'errors/404.pt', b'

This is a pretty 404 page.

'), 47 | (fastapi.status.HTTP_401_UNAUTHORIZED, 'errors/other_error_page.pt', b'

Another pretty 404 page.

'), 48 | (fastapi.status.HTTP_403_FORBIDDEN, 'errors/404.pt', b'

This is a pretty 404 page.

'), 49 | (fastapi.status.HTTP_404_NOT_FOUND, 'errors/other_error_page.pt', b'

Another pretty 404 page.

'), 50 | (fastapi.status.HTTP_405_METHOD_NOT_ALLOWED, 'errors/404.pt', b'

This is a pretty 404 page.

'), 51 | (fastapi.status.HTTP_406_NOT_ACCEPTABLE, 'errors/other_error_page.pt', b'

Another pretty 404 page.

'), 52 | ( 53 | fastapi.status.HTTP_407_PROXY_AUTHENTICATION_REQUIRED, 54 | 'errors/404.pt', 55 | b'

This is a pretty 404 page.

', 56 | ), 57 | (fastapi.status.HTTP_408_REQUEST_TIMEOUT, 'errors/other_error_page.pt', b'

Another pretty 404 page.

'), 58 | ], 59 | ) 60 | def test_friendly_403_async_method(setup_global_template, status_code, template_file, expected_h1_in_body): 61 | @fc.template('home/index.pt') 62 | async def view_method(a, b, c): 63 | fastapi_chameleon.generic_error(template_file, status_code) 64 | return {'a': a, 'b': b, 'c': c} 65 | 66 | resp = asyncio.run(view_method(1, 2, 3)) 67 | assert isinstance(resp, fastapi.Response) 68 | assert resp.status_code == status_code 69 | assert expected_h1_in_body in resp.body 70 | -------------------------------------------------------------------------------- /tests/test_generic_error_data.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import fastapi 4 | import pytest 5 | 6 | import fastapi_chameleon 7 | import fastapi_chameleon as fc 8 | 9 | @pytest.mark.parametrize( 10 | ("status_code", "template_file", "template_data", "expected_p_in_body"), 11 | [ 12 | (fastapi.status.HTTP_400_BAD_REQUEST, "errors/error_with_data.pt", 13 | {"test_data": "this error is given with data"}, b"

this error is given with data

"), 14 | (fastapi.status.HTTP_401_UNAUTHORIZED, "errors/error_with_data.pt", 15 | {"test_data": "this error is given with data"}, b"

this error is given with data

"), 16 | (fastapi.status.HTTP_402_PAYMENT_REQUIRED, "errors/error_with_data.pt", 17 | {"test_data": "this error is given with data"}, b"

this error is given with data

"), 18 | (fastapi.status.HTTP_403_FORBIDDEN, "errors/error_with_data.pt", 19 | {"test_data": "this error is given with data"}, b"

this error is given with data

"), 20 | (fastapi.status.HTTP_404_NOT_FOUND, "errors/error_with_data.pt", 21 | {"test_data": "this error is given with data"}, b"

this error is given with data

"), 22 | ] 23 | ) 24 | def test_data_friendly_generic_sync(setup_global_template, status_code, 25 | template_file, template_data, expected_p_in_body): 26 | @fc.template('home/index.pt') 27 | def view_method(a, b, c): 28 | fastapi_chameleon.generic_error(template_file, status_code, template_data=template_data) 29 | return {'a': a, 'b': b, 'c': c} 30 | 31 | resp = view_method(1, 2, 3) 32 | assert isinstance(resp, fastapi.Response) 33 | assert resp.status_code == status_code 34 | assert expected_p_in_body in resp.body 35 | 36 | 37 | @pytest.mark.parametrize( 38 | ("status_code", "template_file", "template_data", "expected_p_in_body"), 39 | [ 40 | (fastapi.status.HTTP_400_BAD_REQUEST, "errors/error_with_data.pt", 41 | {"test_data": "this error is given with data"}, b"

this error is given with data

"), 42 | (fastapi.status.HTTP_401_UNAUTHORIZED, "errors/error_with_data.pt", 43 | {"test_data": "this error is given with data"}, b"

this error is given with data

"), 44 | (fastapi.status.HTTP_402_PAYMENT_REQUIRED, "errors/error_with_data.pt", 45 | {"test_data": "this error is given with data"}, b"

this error is given with data

"), 46 | (fastapi.status.HTTP_403_FORBIDDEN, "errors/error_with_data.pt", 47 | {"test_data": "this error is given with data"}, b"

this error is given with data

"), 48 | (fastapi.status.HTTP_404_NOT_FOUND, "errors/error_with_data.pt", 49 | {"test_data": "this error is given with data"}, b"

this error is given with data

"), 50 | ] 51 | ) 52 | def test_data_friendly_generic_async(setup_global_template, status_code, 53 | template_file, template_data, expected_p_in_body): 54 | @fc.template('home/index.pt') 55 | async def view_method(a, b, c): 56 | fastapi_chameleon.generic_error(template_file, status_code, template_data=template_data) 57 | return {'a': a, 'b': b, 'c': c} 58 | 59 | resp = asyncio.run(view_method(1, 2, 3)) 60 | assert isinstance(resp, fastapi.Response) 61 | assert resp.status_code == status_code 62 | assert expected_p_in_body in resp.body 63 | 64 | -------------------------------------------------------------------------------- /tests/test_init.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | import fastapi_chameleon as fc 4 | from fastapi_chameleon.exceptions import FastAPIChameleonException 5 | 6 | 7 | def test_cannot_decorate_with_missing_init(): 8 | fc.engine.clear() 9 | 10 | with pytest.raises(FastAPIChameleonException): 11 | 12 | @fc.template('home/index.pt') 13 | def view_method(a, b, c): 14 | return {'a': a, 'b': b, 'c': c} 15 | 16 | view_method(1, 2, 3) 17 | 18 | 19 | def test_can_call_init_with_good_path(test_templates_path): 20 | fc.global_init(str(test_templates_path), cache_init=False) 21 | 22 | # Clear paths so as to no affect future tests 23 | fc.engine.clear() 24 | 25 | 26 | def test_cannot_call_init_with_bad_path(test_templates_path): 27 | bad_path = test_templates_path / 'missing' 28 | with pytest.raises(Exception): 29 | fc.global_init(str(bad_path), cache_init=False) 30 | -------------------------------------------------------------------------------- /tests/test_not_found.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import fastapi 4 | 5 | import fastapi_chameleon 6 | import fastapi_chameleon as fc 7 | 8 | 9 | # setup_global_template - needed as pytest mix-in. 10 | # noinspection PyUnusedLocal 11 | def test_friendly_404_sync_method(setup_global_template): 12 | @fc.template('home/index.pt') 13 | def view_method(a, b, c): 14 | fastapi_chameleon.not_found() 15 | return {'a': a, 'b': b, 'c': c} 16 | 17 | resp = view_method(1, 2, 3) 18 | assert isinstance(resp, fastapi.Response) 19 | assert resp.status_code == 404 20 | assert b'

This is a pretty 404 page.

' in resp.body 21 | 22 | 23 | # setup_global_template - needed as pytest mix-in. 24 | # noinspection PyUnusedLocal 25 | def test_friendly_404_custom_template_sync_method(setup_global_template): 26 | @fc.template('home/index.pt') 27 | def view_method(a, b, c): 28 | fastapi_chameleon.not_found(four04template_file='errors/other_error_page.pt') 29 | return {'a': a, 'b': b, 'c': c} 30 | 31 | resp = view_method(1, 2, 3) 32 | assert isinstance(resp, fastapi.Response) 33 | assert resp.status_code == 404 34 | assert b'

Another pretty 404 page.

' in resp.body 35 | 36 | 37 | # setup_global_template - needed as pytest mix-in. 38 | # noinspection PyUnusedLocal 39 | def test_friendly_404_async_method(setup_global_template): 40 | @fc.template('home/index.pt') 41 | async def view_method(a, b, c): 42 | fastapi_chameleon.not_found() 43 | return {'a': a, 'b': b, 'c': c} 44 | 45 | resp = asyncio.run(view_method(1, 2, 3)) 46 | assert isinstance(resp, fastapi.Response) 47 | assert resp.status_code == 404 48 | assert b'

This is a pretty 404 page.

' in resp.body 49 | -------------------------------------------------------------------------------- /tests/test_render.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | import fastapi 4 | # noinspection PyPackageRequirements 5 | import pytest 6 | 7 | import fastapi_chameleon as fc 8 | 9 | 10 | # setup_global_template - needed as pytest mix-in. 11 | # noinspection PyUnusedLocal 12 | def test_cannot_decorate_missing_template(setup_global_template): 13 | with pytest.raises(ValueError): 14 | 15 | @fc.template('home/missing.pt') 16 | def view_method(): 17 | return {} 18 | 19 | view_method() 20 | 21 | 22 | # setup_global_template - needed as pytest mix-in. 23 | # noinspection PyUnusedLocal 24 | def test_requires_template_for_default_name(setup_global_template): 25 | with pytest.raises(ValueError): 26 | 27 | @fc.template(None) 28 | def view_method(): 29 | return {} 30 | 31 | view_method() 32 | 33 | 34 | # setup_global_template - needed as pytest mix-in. 35 | # noinspection PyUnusedLocal 36 | def test_default_template_name_pt(setup_global_template): 37 | @fc.template() 38 | def index(a, b, c): 39 | return {'a': a, 'b': b, 'c': c, 'world': 'WORLD'} 40 | 41 | resp = index(1, 2, 3) 42 | assert isinstance(resp, fastapi.Response) 43 | assert resp.status_code == 200 44 | html = resp.body.decode('utf-8') 45 | assert '

Hello default WORLD!

' in html 46 | 47 | 48 | # setup_global_template - needed as pytest mix-in. 49 | # noinspection PyUnusedLocal 50 | def test_default_template_name_no_parentheses(setup_global_template): 51 | @fc.template 52 | def index(a, b, c): 53 | return {'a': a, 'b': b, 'c': c, 'world': 'WORLD'} 54 | 55 | resp = index(1, 2, 3) 56 | assert isinstance(resp, fastapi.Response) 57 | assert resp.status_code == 200 58 | html = resp.body.decode('utf-8') 59 | assert '

Hello default WORLD!

' in html 60 | 61 | 62 | def test_default_template_name_html(setup_global_template): 63 | @fc.template() 64 | def details(a, b, c): 65 | return {'a': a, 'b': b, 'c': c, 'world': 'WORLD'} 66 | 67 | resp = details(1, 2, 3) 68 | assert isinstance(resp, fastapi.Response) 69 | assert resp.status_code == 200 70 | html = resp.body.decode('utf-8') 71 | assert '

Hello default WORLD!

' in html 72 | 73 | 74 | # setup_global_template - needed as pytest mix-in. 75 | # noinspection PyUnusedLocal 76 | def test_can_decorate_dict_sync_method(setup_global_template): 77 | @fc.template('home/index.pt') 78 | def view_method(a, b, c): 79 | return {'a': a, 'b': b, 'c': c} 80 | 81 | resp = view_method(1, 2, 3) 82 | assert isinstance(resp, fastapi.Response) 83 | assert resp.status_code == 200 84 | 85 | 86 | def test_can_decorate_dict_async_method(setup_global_template): 87 | @fc.template('home/index.pt') 88 | async def view_method(a, b, c): 89 | return {'a': a, 'b': b, 'c': c} 90 | 91 | resp = asyncio.run(view_method(1, 2, 3)) 92 | assert isinstance(resp, fastapi.Response) 93 | assert resp.status_code == 200 94 | 95 | 96 | def test_direct_response_pass_through(): 97 | @fc.template('home/index.pt') 98 | def view_method(a, b, c): 99 | return fastapi.Response(content='abc', status_code=418) 100 | 101 | resp = view_method(1, 2, 3) 102 | assert isinstance(resp, fastapi.Response) 103 | assert resp.status_code == 418 104 | assert resp.body == b'abc' 105 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py39,py310,py311,py312,py313 3 | 4 | [testenv] 5 | commands = pytest fastapi-chameleon 6 | deps = pytest 7 | --------------------------------------------------------------------------------