├── .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 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
73 |
74 |
75 |
76 |
77 |
78 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/profiles_settings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/.idea/misc.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
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 |
7 | ${row.capitalize()} ${col}
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/tests/templates/test_render/details.html:
--------------------------------------------------------------------------------
1 |
2 |
3 | Hello default ${world}!
4 |
5 |
6 |
7 | ${row.capitalize()} ${col}
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/tests/templates/test_render/index.pt:
--------------------------------------------------------------------------------
1 |
2 |
3 | Hello default ${world}!
4 |
5 |
6 |
7 | ${row.capitalize()} ${col}
8 |
9 |
10 |
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 |
--------------------------------------------------------------------------------