Skip to content

Commit 1731a3e

Browse files
authored
Added gunicorn support (#178)
1 parent 7bf6e06 commit 1731a3e

File tree

8 files changed

+155
-12
lines changed

8 files changed

+155
-12
lines changed

fastapi_template/cli.py

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,15 @@
99
from prompt_toolkit.validation import ValidationError, Validator
1010
from termcolor import colored
1111

12-
from fastapi_template.input_model import (SKIP_ENTRY, BaseMenuModel,
13-
BuilderContext, Database, MenuEntry,
14-
MultiselectMenuModel,
15-
SingularMenuModel)
12+
from fastapi_template.input_model import (
13+
SKIP_ENTRY,
14+
BaseMenuModel,
15+
BuilderContext,
16+
Database,
17+
MenuEntry,
18+
MultiselectMenuModel,
19+
SingularMenuModel,
20+
)
1621

1722

1823
class SnakeCaseValidator(Validator):
@@ -509,6 +514,17 @@ def checker(ctx: BuilderContext) -> bool:
509514
)
510515
),
511516
),
517+
MenuEntry(
518+
code="gunicorn",
519+
cli_name="gunicorn",
520+
user_view="Add gunicorn server",
521+
description=(
522+
"This option adds {what} server for running application.\n"
523+
"It's more performant than uvicorn, and recommended for production.".format(
524+
what=colored("gunicorn", color="green")
525+
)
526+
),
527+
),
512528
],
513529
)
514530

fastapi_template/template/cookiecutter.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,9 @@
6262
"pydanticv1": {
6363
"type": "bool"
6464
},
65+
"gunicorn": {
66+
"type": "bool"
67+
},
6568
"_extensions": [
6669
"cookiecutter.extensions.RandomStringExtension"
6770
],

fastapi_template/template/{{cookiecutter.project_name}}/.pre-commit-config.yaml

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,19 +26,19 @@ repos:
2626

2727
- repo: local
2828
hooks:
29-
- id: black
30-
name: Format with Black
31-
entry: poetry run black
32-
language: system
33-
types: [python]
34-
3529
- id: autoflake
3630
name: autoflake
3731
entry: poetry run autoflake
3832
language: system
3933
types: [python]
4034
args: [--in-place, --remove-all-unused-imports, --remove-duplicate-keys]
4135

36+
- id: black
37+
name: Format with Black
38+
entry: poetry run black
39+
language: system
40+
types: [python]
41+
4242
- id: isort
4343
name: isort
4444
entry: poetry run isort

fastapi_template/template/{{cookiecutter.project_name}}/conditional_files.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,5 +215,11 @@
215215
"resources": [
216216
"{{cookiecutter.project_name}}/tkq.py"
217217
]
218+
},
219+
"Gunicorn support":{
220+
"enabled": "{{cookiecutter.gunicorn}}",
221+
"resources": [
222+
"{{cookiecutter.project_name}}/gunicorn_runner.py"
223+
]
218224
}
219225
}

fastapi_template/template/{{cookiecutter.project_name}}/pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ readme = "README.md"
1414
python = "^3.9"
1515
fastapi = "^0.100.0"
1616
uvicorn = { version = "^0.22.0", extras = ["standard"] }
17+
{%- if cookiecutter.gunicorn == "True" %}
18+
gunicorn = "^21.2.0"
19+
{%- endif %}
1720
{%- if cookiecutter.pydanticv1 == "True" %}
1821
pydantic = { version = "^1", extras=["dotenv"] }
1922
{%- else %}

fastapi_template/template/{{cookiecutter.project_name}}/{{cookiecutter.project_name}}/__main__.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22
import shutil
33

44
import uvicorn
5+
6+
{%- if cookiecutter.gunicorn == "True" %}
7+
from {{cookiecutter.project_name}}.gunicorn_runner import GunicornApplication
8+
{%- endif %}
59
from {{cookiecutter.project_name}}.settings import settings
610

711
{%- if cookiecutter.prometheus_enabled == "True" %}
@@ -39,6 +43,32 @@ def main() -> None:
3943
{%- if cookiecutter.orm == "piccolo" %}
4044
os.environ['PICCOLO_CONF'] = "{{cookiecutter.project_name}}.piccolo_conf"
4145
{%- endif %}
46+
{%- if cookiecutter.gunicorn == "True" %}
47+
if settings.reload:
48+
uvicorn.run(
49+
"{{cookiecutter.project_name}}.web.application:get_app",
50+
workers=settings.workers_count,
51+
host=settings.host,
52+
port=settings.port,
53+
reload=settings.reload,
54+
log_level=settings.log_level.value.lower(),
55+
factory=True,
56+
)
57+
else:
58+
# We choose gunicorn only if reload
59+
# option is not used, because reload
60+
# feature doen't work with Uvicorn workers.
61+
GunicornApplication(
62+
"{{cookiecutter.project_name}}.web.application:get_app",
63+
host=settings.host,
64+
port=settings.port,
65+
workers=settings.workers_count,
66+
factory=True,
67+
accesslog="-",
68+
loglevel=settings.log_level.value.lower(),
69+
access_log_format='%r "-" %s "-" %Tf', # noqa: WPS323
70+
).run()
71+
{%- else %}
4272
uvicorn.run(
4373
"{{cookiecutter.project_name}}.web.application:get_app",
4474
workers=settings.workers_count,
@@ -48,7 +78,7 @@ def main() -> None:
4878
log_level=settings.log_level.value.lower(),
4979
factory=True,
5080
)
51-
81+
{%- endif %}
5282

5383
if __name__ == "__main__":
5484
main()
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
from typing import Any
2+
3+
from gunicorn.app.base import BaseApplication
4+
from gunicorn.util import import_app
5+
from uvicorn.workers import UvicornWorker as BaseUvicornWorker
6+
7+
try:
8+
import uvloop # noqa: WPS433 (Found nested import)
9+
except ImportError:
10+
uvloop = None # type: ignore # noqa: WPS440 (variables overlap)
11+
12+
13+
14+
class UvicornWorker(BaseUvicornWorker):
15+
"""
16+
Configuration for uvicorn workers.
17+
18+
This class is subclassing UvicornWorker and defines
19+
some parameters class-wide, because it's impossible,
20+
to pass these parameters through gunicorn.
21+
"""
22+
23+
CONFIG_KWARGS = { # noqa: WPS115 (upper-case constant in a class)
24+
"loop": "uvloop" if uvloop is not None else "asyncio",
25+
"http": "httptools",
26+
"lifespan": "on",
27+
"factory": True,
28+
"proxy_headers": False,
29+
}
30+
31+
32+
class GunicornApplication(BaseApplication):
33+
"""
34+
Custom gunicorn application.
35+
36+
This class is used to start guncicorn
37+
with custom uvicorn workers.
38+
"""
39+
40+
def __init__( # noqa: WPS211 (Too many args)
41+
self,
42+
app: str,
43+
host: str,
44+
port: int,
45+
workers: int,
46+
**kwargs: Any,
47+
):
48+
self.options = {
49+
"bind": f"{host}:{port}",
50+
"workers": workers,
51+
"worker_class": "{{cookiecutter.project_name}}.gunicorn_runner.UvicornWorker",
52+
**kwargs
53+
}
54+
self.app = app
55+
super().__init__()
56+
57+
def load_config(self) -> None:
58+
"""
59+
Load config for web server.
60+
61+
This function is used to set parameters to gunicorn
62+
main process. It only sets parameters that
63+
gunicorn can handle. If you pass unknown
64+
parameter to it, it crash with error.
65+
"""
66+
for key, value in self.options.items():
67+
if key in self.cfg.settings and value is not None:
68+
self.cfg.set(key.lower(), value)
69+
70+
def load(self) -> str:
71+
"""
72+
Load actual application.
73+
74+
Gunicorn loads application based on this
75+
function's returns. We return python's path to
76+
the app's factory.
77+
78+
:returns: python path to app factory.
79+
"""
80+
return import_app(self.app)

fastapi_template/tests/test_generator.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ def init_context(
2929
if entry.code == orm:
3030
if entry.pydantic_v1:
3131
context.pydanticv1 = True
32-
32+
3333
if api is not None:
3434
context.api_type = api
3535
if api == "graphql":
@@ -186,6 +186,11 @@ def test_telemetry_pre_commit(default_context: BuilderContext):
186186
run_default_check(default_context, without_pytest=True)
187187

188188

189+
def test_gunicorn(default_context: BuilderContext):
190+
default_context.gunicorn = True
191+
run_default_check(default_context, without_pytest=True)
192+
193+
189194
# @pytest.mark.parametrize("api", ["rest", "graphql"])
190195
# def test_kafka(default_context: BuilderContext, api: str):
191196
# default_context.enable_kafka = True

0 commit comments

Comments
 (0)