Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat(extension): Enable Async Gunicorn workers for Flask and Django e… #747

Open
wants to merge 20 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
c0d3102
Feat(extension): Enable Async Gunicorn workers for Flask and Django e…
alithethird Nov 8, 2024
009fe4c
chore(lint): Ran static checkers
alithethird Nov 8, 2024
a6d156f
chore(): Fix flask cli unit test
alithethird Nov 8, 2024
408db1d
chore(docs): Docs lint
alithethird Nov 8, 2024
39671e1
chore(docs): Improved wordlist
alithethird Nov 8, 2024
4220cd7
Merge branch 'main' into flask-django-extention-async-workers
alithethird Nov 15, 2024
8d88e8c
Chore(): Update docs, update cli init test, update `rockcraft.yaml` f…
alithethird Nov 19, 2024
fbd636c
Merge branch 'flask-django-extention-async-workers' of github.com:ali…
alithethird Nov 19, 2024
3280aa6
Chore(): Try to make linter happy
alithethird Nov 19, 2024
c9fab57
Chore(docs): Lint docs
alithethird Nov 19, 2024
a1df1b5
Chore(docs): Update async doc link
alithethird Nov 20, 2024
8446f7e
Merge branch 'main' into flask-django-extention-async-workers
alithethird Nov 20, 2024
0ea1838
Run CI
alithethird Nov 20, 2024
a8e14d2
Merge branch 'flask-django-extention-async-workers' of github.com:ali…
alithethird Nov 20, 2024
99982f8
Chore(Docs): Small doc improvements
alithethird Nov 26, 2024
a645957
Merge branch 'main' into flask-django-extention-async-workers
alithethird Nov 26, 2024
f35db9e
Chore(lint): Doc lint
alithethird Nov 26, 2024
b433599
XMerge branch 'flask-django-extention-async-workers' of github.com:al…
alithethird Nov 26, 2024
56979d4
Chore(docs): Add words to list
alithethird Nov 26, 2024
7c57358
Chore(): Undone import format
alithethird Nov 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/.wordlist.txt
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ AMD
amd
ARGS
ASGI
async
Autotools
autotools
boolean
Expand Down Expand Up @@ -43,10 +44,12 @@ filesystem
filesystems
fs
gc
gevent
GiB
GID
github
GPG
gunicorn
Gunicorn
gzipped
hardcoded
Expand Down Expand Up @@ -143,6 +146,7 @@ triaged
ubuntu
unbuilt
UID
uncomment
usrmerge
Uvicorn
VENV
Expand Down
24 changes: 24 additions & 0 deletions docs/reference/extensions/django-framework.rst
alithethird marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,30 @@ application. In the following example we use it to specify ``libpq-dev``:
# list required packages or slices for your Django application below.
- libpq-dev

.. warning::
You can only use 1 of the dependencies parts at a time.

``parts`` > ``django-framework/async-dependencies``
=================================================================

You can use this key to specify that you want to use async gunicorn workers in
your Django application. It also works just like
``django-framework/dependencies``.

Just uncomment the following lines:
.. code-block:: yaml

parts:
django-framework/async-dependencies:
python-packages:
- gunicorn[gevent]

If your project needs additional debs to run, you can add them to
``stage-packages`` just like it is done in ``django-framework/dependencies``.

.. warning::
You can only use 1 of the dependencies parts at a time.

Useful links
============

Expand Down
24 changes: 24 additions & 0 deletions docs/reference/extensions/flask-framework.rst
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,30 @@ application. In the following example we use it to specify ``libpq-dev``:
# list required packages or slices for your flask app below.
- libpq-dev

.. warning::
You can only use 1 of the dependencies parts at a time.

``parts`` > ``flask-framework/async-dependencies``
=================================================================

You can use this key to specify that you want to use async gunicorn workers in
your Flask application. It also works just like
``flask-framework/dependencies``.

Just uncomment the following lines:
.. code-block:: yaml

parts:
flask-framework/async-dependencies:
python-packages:
- gunicorn[gevent]

If your project needs additional debs to run, you can add them to
``stage-packages`` just like it is done in ``flask-framework/dependencies``.

.. warning::
You can only use 1 of the dependencies parts at a time.

``parts`` > ``flask-framework/install-app`` > ``prime``
=======================================================

Expand Down
10 changes: 10 additions & 0 deletions rockcraft/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,11 @@ class InitCommand(AppCommand):
# - flask/app/templates
# - flask/app/static

# uncomment this section to enable the async workers for Gunicorn.
# flask-framework/async-dependencies:
# python-packages:
# - gunicorn[gevent]

# you may need Ubuntu packages to build a python dependency. Add them here if necessary.
# flask-framework/dependencies:
# build-packages:
Expand Down Expand Up @@ -208,6 +213,11 @@ class InitCommand(AppCommand):
# stage-packages:
# # list required packages or slices for your Django application below.
# - libpq-dev
# uncomment this section to enable the async workers for Gunicorn.
# django-framework/async-dependencies:
# python-packages:
# - gunicorn[gevent]

"""
)
),
Expand Down
37 changes: 35 additions & 2 deletions rockcraft/extensions/gunicorn.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,15 +71,28 @@ def _gen_parts(self) -> dict:
stage_packages = ["python3.10-venv_ensurepip"]
build_environment = [{"PARTS_PYTHON_INTERPRETER": "python3.10"}]

parts: dict[str, Any] = {
sync_dependencies: dict[str, Any] = {
f"{self.framework}-framework/dependencies": {
"plugin": "python",
"stage-packages": stage_packages,
"source": ".",
"python-packages": ["gunicorn"],
"python-requirements": ["requirements.txt"],
"build-environment": build_environment,
},
}
}
async_dependencies: dict[str, Any] = {
f"{self.framework}-framework/async-dependencies": {
"plugin": "python",
"stage-packages": stage_packages,
"source": ".",
"python-packages": ["gunicorn[gevent]"],
"python-requirements": ["requirements.txt"],
"build-environment": build_environment,
}
}

parts: dict[str, Any] = {
f"{self.framework}-framework/install-app": self.gen_install_app_part(),
f"{self.framework}-framework/config-files": {
"plugin": "dump",
Expand All @@ -95,6 +108,13 @@ def _gen_parts(self) -> dict:
"source": "https://github.com/prometheus/statsd_exporter.git",
},
}
if f"{self.framework}-framework/async-dependencies" in self.yaml_data.get(
"parts", {}
):
parts = dict(async_dependencies, **parts)
else:
parts = dict(sync_dependencies, **parts)

if self.yaml_data["base"] == "bare":
parts[f"{self.framework}-framework/runtime"] = {
"plugin": "nil",
Expand Down Expand Up @@ -273,12 +293,25 @@ def _requirements_txt_error_messages(self) -> list[str]:

return []

def _dependencies_error_messages(self) -> list[str]:
"""Ensure only 1 of the dependencies parts is defined."""
yaml_parts = self.yaml_data.get("parts", {})

if yaml_parts.get(
f"{self.framework}-framework/async-dependencies", None
) and yaml_parts.get(f"{self.framework}-framework/dependencies", None):
return [
f"Cannot have both sync and async dependencies. https://bit.ly/{self.framework}-async-doc"
]
return []

@override
def check_project(self) -> None:
"""Ensure this extension can apply to the current rockcraft project."""
error_messages = self._requirements_txt_error_messages()
if not self.yaml_data.get("services", {}).get("flask", {}).get("command"):
error_messages += self._wsgi_path_error_messages()
error_messages += self._dependencies_error_messages()
if error_messages:
raise ExtensionError(
"\n".join("- " + message for message in error_messages),
Expand Down
2 changes: 1 addition & 1 deletion rockcraft/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -483,7 +483,7 @@ def load_project(filename: Path) -> dict[str, Any]:
msg = err.strerror or "unknown"
if err.filename:
msg = f"{msg}: {err.filename!r}."
raise ProjectLoadError(msg) from err
raise ProjectLoadError(str(msg)) from err

return transform_yaml(filename.parent, yaml_data)

Expand Down
182 changes: 182 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,188 @@ def new_dir(tmpdir):
os.chdir(cwd)


@pytest.fixture()
def django_dir(new_dir):
"""Change to a new temporary directory."""
(new_dir / "requirements.txt").write_text("Django", encoding="utf-8")
new_dir.mkdir("test_name")
new_dir.mkdir("test_name/test_name")
(new_dir / "test_name/manage.py").write_text(
"""\
#!/usr/bin/env python
\"\"\"Django's command-line utility for administrative tasks.\"\"\"
import os
import sys


def main():
\"\"\"Run administrative tasks.\"\"\"
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'test_name.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
\"Couldn't import Django. Are you sure it's installed and \"
\"available on your PYTHONPATH environment variable? Did you \"
\"forget to activate a virtual environment?\"
) from exc
execute_from_command_line(sys.argv)


if __name__ == '__main__':
main()

""",
encoding="utf-8",
)
(new_dir / "test_name/test_name/urls.py").write_text(
"""\
from django.contrib import admin
from django.urls import path

urlpatterns = [
path('admin/', admin.site.urls),
]
""",
encoding="utf-8",
)
(new_dir / "test_name/test_name/__init__.py").write_text("", encoding="utf-8")
(new_dir / "test_name/test_name/settings.py").write_text(
"""\
\"\"\"
Django settings for test_name project.

Generated by 'django-admin startproject' using Django 5.1.3.

For more information on this file, see
https://docs.djangoproject.com/en/5.1/topics/settings/

For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.1/ref/settings/
\"\"\"

from pathlib import Path

# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent


# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.1/howto/deployment/checklist/

# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-s!661fk_mhkv!thlq1j&o+%7&%(djz+ir=6^+o$jtgbf(_2t_s'

# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True

ALLOWED_HOSTS = []


# Application definition

INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
]

MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

ROOT_URLCONF = 'test_name.urls'

TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]

WSGI_APPLICATION = 'test_name.wsgi.application'


# Database
# https://docs.djangoproject.com/en/5.1/ref/settings/#databases

DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}


# Password validation
# https://docs.djangoproject.com/en/5.1/ref/settings/#auth-password-validators

AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]


# Internationalization
# https://docs.djangoproject.com/en/5.1/topics/i18n/

LANGUAGE_CODE = 'en-us'

TIME_ZONE = 'UTC'

USE_I18N = True

USE_TZ = True


# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.1/howto/static-files/

STATIC_URL = 'static/'

# Default primary key field type
# https://docs.djangoproject.com/en/5.1/ref/settings/#default-auto-field

DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
""",
encoding="utf-8",
)

(new_dir / "test_name/test_name/wsgi.py").write_text(
"import os\nfrom django.core.wsgi import get_wsgi_application\nos.environ.setdefault('DJANGO_SETTINGS_MODULE', 'django_app.settings')\napplication = get_wsgi_application()\n",
encoding="utf-8",
)
return new_dir


@pytest.fixture(autouse=True)
def temp_xdg(tmpdir, mocker):
"""Use a temporary location for XDG directories."""
Expand Down
Loading
Loading