diff --git a/docs/.wordlist.txt b/docs/.wordlist.txt index 4cc593c89..fb90e0030 100644 --- a/docs/.wordlist.txt +++ b/docs/.wordlist.txt @@ -3,6 +3,7 @@ AMD amd ARGS ASGI +async Autotools autotools boolean @@ -44,10 +45,12 @@ filesystems filetree fs gc +gevent GiB GID github GPG +gunicorn Gunicorn gzipped hardcoded @@ -144,6 +147,7 @@ triaged ubuntu unbuilt UID +uncomment usrmerge Uvicorn VENV diff --git a/docs/reference/extensions/django-framework.rst b/docs/reference/extensions/django-framework.rst index 910c1c63f..0a775c489 100644 --- a/docs/reference/extensions/django-framework.rst +++ b/docs/reference/extensions/django-framework.rst @@ -17,6 +17,9 @@ server metrics. The Django extension is compatible with the ``bare``, ``ubuntu@22.04`` and ``ubuntu@24.04`` bases. +The Django extension supports both synchronous and asynchronous +Gunicorn workers. + Project requirements ==================== @@ -29,6 +32,11 @@ There are 2 requirements to be able to use the ``django-framework`` extension: ``.///manage.py`` relative to the ``rockcraft.yaml`` file. +For the project to make use of asynchronous Gunicorn workers: + +- The ``requirements.txt`` file must include ``gevent`` as a dependency. + + ``parts`` > ``django-framework/dependencies:`` > ``stage-packages`` =================================================================== @@ -43,6 +51,23 @@ application. In the following example we use it to specify ``libpq-dev``: # list required packages or slices for your Django application below. - libpq-dev +.. _django-gunicorn-worker-selection: + +Gunicorn worker selection +========================= + +If the project has gevent as a dependency, Rockcraft automatically updates the +pebble plan to spawn asynchronous Gunicorn workers. + +When the project instead needs synchronous workers, you can override the worker +type by adding ``--args django sync`` to the Docker command that launches the +rock: + +.. code-block:: bash + + docker run --name django-container -d -p 8000:8000 django-image:1.0 \ + --args django sync + Useful links ============ diff --git a/docs/reference/extensions/flask-framework.rst b/docs/reference/extensions/flask-framework.rst index 319c6f519..201d40e96 100644 --- a/docs/reference/extensions/flask-framework.rst +++ b/docs/reference/extensions/flask-framework.rst @@ -16,6 +16,9 @@ server metrics. The Flask extension is compatible with the ``bare``, ``ubuntu@22.04`` and ``ubuntu@24.04`` bases. +The Flask extension supports both synchronous and asynchronous +Gunicorn workers. + Project requirements ==================== @@ -25,7 +28,12 @@ There are 2 requirements to be able to use the ``flask-framework`` extension: ``Flask`` declared as a dependency 2. The project must include a WSGI app with the path ``app:app``. This means there must be an ``app.py`` file at the root of the project with the name - of the Flask object is set to ``app`` + of the Flask object is set to ``app``. + +For the project to make use of asynchronous Gunicorn workers: + +- The ``requirements.txt`` file must include ``gevent`` as a dependency. + ``parts`` > ``flask-framework/dependencies`` > ``stage-packages`` ================================================================= @@ -41,6 +49,23 @@ application. In the following example we use it to specify ``libpq-dev``: # list required packages or slices for your flask app below. - libpq-dev +.. _flask-gunicorn-worker-selection: + +Gunicorn worker selection +========================= + +If the project has gevent as a dependency, Rockcraft automatically updates the +pebble plan to spawn asynchronous Gunicorn workers. + +When the project instead needs synchronous workers, you can override the worker +type by adding ``--args flask sync`` to the Docker command that launches the +rock: + +.. code-block:: bash + + docker run --name flask-container -d -p 8000:8000 flask-image:1.0 \ + --args flask sync + ``parts`` > ``flask-framework/install-app`` > ``prime`` ======================================================= diff --git a/rockcraft/extensions/gunicorn.py b/rockcraft/extensions/gunicorn.py index 890a6af29..e7be8681d 100644 --- a/rockcraft/extensions/gunicorn.py +++ b/rockcraft/extensions/gunicorn.py @@ -23,6 +23,7 @@ from typing import Any from overrides import override +from packaging.requirements import InvalidRequirement, Requirement from ..errors import ExtensionError from ._python_utils import has_global_variable @@ -112,6 +113,19 @@ def _gen_parts(self) -> dict: } return parts + def _check_async(self) -> str: + """Check if gevent package installed in requirements.txt.""" + requirements_file = self.project_root / "requirements.txt" + requirements_text = requirements_file.read_text() + for line in requirements_text.splitlines(): + try: + req = Requirement(line) + if req.name == "gevent": + return "gevent" + except InvalidRequirement: + pass + return "sync" + @override def get_root_snippet(self) -> dict[str, Any]: """Fill in some default root components. @@ -130,7 +144,7 @@ def get_root_snippet(self) -> dict[str, Any]: self.framework: { "override": "replace", "startup": "enabled", - "command": f"/bin/python3 -m gunicorn -c /{self.framework}/gunicorn.conf.py {self.wsgi_path}", + "command": f"/bin/python3 -m gunicorn -c /{self.framework}/gunicorn.conf.py {self.wsgi_path} -k [ {self._check_async()} ]", "after": ["statsd-exporter"], "user": "_daemon_", }, diff --git a/rockcraft/models/project.py b/rockcraft/models/project.py index 504169a73..58c6bfc49 100644 --- a/rockcraft/models/project.py +++ b/rockcraft/models/project.py @@ -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) diff --git a/tests/data/django/expected_rockcraft.yaml b/tests/data/django/expected_rockcraft.yaml new file mode 100644 index 000000000..4aa79be25 --- /dev/null +++ b/tests/data/django/expected_rockcraft.yaml @@ -0,0 +1,43 @@ +name: test-name +# see https://documentation.ubuntu.com/rockcraft/en/latest/explanation/bases/ +# for more information about bases and using 'bare' bases for chiselled rocks +base: ubuntu@22.04 # the base environment for this Django application +version: '0.1' # just for humans. Semantic versioning is recommended +summary: A summary of your Django application # 79 char long summary +description: | + This is test-name's description. You have a paragraph or two to tell the + most important story about it. Keep it under 100 words though, + we live in tweetspace and your description wants to look good in the + container registries out there. +# the platforms this rock should be built on and run on. +# you can check your architecture with `dpkg --print-architecture` +platforms: + amd64: + # arm64: + # ppc64el: + # s390x: + +# to ensure the django-framework extension functions properly, your Django project +# should have a structure similar to the following with ./test_name/test_name/wsgi.py +# being the WSGI entry point and contain an application object. +# +-- test_name +# | |-- test_name +# | | |-- wsgi.py +# | | +-- ... +# | |-- manage.py +# | |-- migrate.sh +# | +-- some_app +# | |-- views.py +# | +-- ... +# |-- requirements.txt +# +-- rockcraft.yaml + +extensions: + - django-framework + +# uncomment the sections you need and adjust according to your requirements. +# parts: +# django-framework/dependencies: +# stage-packages: +# # list required packages or slices for your Django application below. +# - libpq-dev diff --git a/tests/data/django/requirements.txt b/tests/data/django/requirements.txt new file mode 100644 index 000000000..94a0e8344 --- /dev/null +++ b/tests/data/django/requirements.txt @@ -0,0 +1 @@ +Django diff --git a/tests/data/django/test_name/test_name/wsgi.py b/tests/data/django/test_name/test_name/wsgi.py new file mode 100644 index 000000000..f3ae12644 --- /dev/null +++ b/tests/data/django/test_name/test_name/wsgi.py @@ -0,0 +1,7 @@ +import os + +# Rockcraft repo doesn't need Django installed so ignore module-not-found mypy error +from django.core.wsgi import get_wsgi_application # type: ignore + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "django_app.settings") +application = get_wsgi_application() diff --git a/tests/data/flask/app.py b/tests/data/flask/app.py new file mode 100644 index 000000000..cb9ef2f2e --- /dev/null +++ b/tests/data/flask/app.py @@ -0,0 +1 @@ +app = object() diff --git a/tests/data/flask/expected_rockcraft.yaml b/tests/data/flask/expected_rockcraft.yaml new file mode 100644 index 000000000..cf6918129 --- /dev/null +++ b/tests/data/flask/expected_rockcraft.yaml @@ -0,0 +1,71 @@ +name: test-name +# see https://documentation.ubuntu.com/rockcraft/en/latest/explanation/bases/ +# for more information about bases and using 'bare' bases for chiselled rocks +base: ubuntu@22.04 # the base environment for this Flask application +version: '0.1' # just for humans. Semantic versioning is recommended +summary: A summary of your Flask application # 79 char long summary +description: | + This is test-name's description. You have a paragraph or two to tell the + most important story about it. Keep it under 100 words though, + we live in tweetspace and your description wants to look good in the + container registries out there. +# the platforms this rock should be built on and run on. +# you can check your architecture with `dpkg --print-architecture` +platforms: + amd64: + # arm64: + # ppc64el: + # s390x: + +# to ensure the flask-framework extension works properly, your Flask application +# should have an `app.py` file with an `app` object as the WSGI entrypoint. +# a `requirements.txt` file with at least the flask package should also exist. +# see https://documentation.ubuntu.com/rockcraft/en/latest/reference/extensions/flask-framework +# for more information. +extensions: + - flask-framework + +# uncomment the sections you need and adjust according to your requirements. +# parts: # you need to uncomment this line to add or update any part. + +# flask-framework/install-app: +# prime: +# # by default, only the files in app/, templates/, static/, migrate, migrate.sh, +# # migrate.py and app.py are copied into the image. You can modify the list +# # below to override the default list and include or exclude specific +# # files/directories in your project. +# # note: prefix each entry with "flask/app/" followed by the local path. +# - flask/app/.env +# - flask/app/app.py +# - flask/app/webapp +# - flask/app/templates +# - flask/app/static + +# you may need Ubuntu packages to build a python dependency. Add them here if necessary. +# flask-framework/dependencies: +# build-packages: +# # for example, if you need pkg-config and libxmlsec1-dev to build one +# # of your packages: +# - pkg-config +# - libxmlsec1-dev + +# you can add package slices or Debian packages to the image. +# package slices are subsets of Debian packages, which result +# in smaller and more secure images. +# see https://documentation.ubuntu.com/rockcraft/en/latest/explanation/chisel/ + +# add this part if you want to add packages slices to your image. +# you can find a list of packages slices at https://github.com/canonical/chisel-releases +# runtime-slices: +# plugin: nil +# stage-packages: +# # list the required package slices for your flask application below. +# # for example, for the slice libs of libpq5: +# - libpq5_libs + +# if you want to add a Debian package to your image, add the next part +# runtime-debs: +# plugin: nil +# stage-packages: +# # list required Debian packages for your flask application below. +# - libpq5 diff --git a/tests/data/flask/requirements.txt b/tests/data/flask/requirements.txt new file mode 100644 index 000000000..e3e9a71d9 --- /dev/null +++ b/tests/data/flask/requirements.txt @@ -0,0 +1 @@ +Flask diff --git a/tests/spread/rockcraft/extension-flask-async/README b/tests/spread/rockcraft/extension-flask-async/README new file mode 100644 index 000000000..30d74d258 --- /dev/null +++ b/tests/spread/rockcraft/extension-flask-async/README @@ -0,0 +1 @@ +test \ No newline at end of file diff --git a/tests/spread/rockcraft/extension-flask-async/app.py b/tests/spread/rockcraft/extension-flask-async/app.py new file mode 100644 index 000000000..84cc4f665 --- /dev/null +++ b/tests/spread/rockcraft/extension-flask-async/app.py @@ -0,0 +1,16 @@ +from time import sleep + +from flask import Flask # pyright: ignore[reportMissingImports] + +app = Flask(__name__) + + +@app.route("/") +def ok(): + return "ok" + + +@app.route("/io") +def pseudo_io(): + sleep(2) + return "ok" diff --git a/tests/spread/rockcraft/extension-flask-async/node_modules/.gitkeep b/tests/spread/rockcraft/extension-flask-async/node_modules/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/tests/spread/rockcraft/extension-flask-async/requirements.txt b/tests/spread/rockcraft/extension-flask-async/requirements.txt new file mode 100644 index 000000000..652376aea --- /dev/null +++ b/tests/spread/rockcraft/extension-flask-async/requirements.txt @@ -0,0 +1,2 @@ +flask +gevent diff --git a/tests/spread/rockcraft/extension-flask-async/static/js/test.js b/tests/spread/rockcraft/extension-flask-async/static/js/test.js new file mode 100644 index 000000000..5be36ccb7 --- /dev/null +++ b/tests/spread/rockcraft/extension-flask-async/static/js/test.js @@ -0,0 +1 @@ +console.log("hello") \ No newline at end of file diff --git a/tests/spread/rockcraft/extension-flask-async/task.yaml b/tests/spread/rockcraft/extension-flask-async/task.yaml new file mode 100644 index 000000000..3f3044499 --- /dev/null +++ b/tests/spread/rockcraft/extension-flask-async/task.yaml @@ -0,0 +1,126 @@ +summary: flask extension async test +environment: + SCENARIO/bare: bare + SCENARIO/base_2204: ubuntu-22.04 + SCENARIO/base_2404: ubuntu-24.04 +execute: | + NAME="flask-${SCENARIO//./-}" + ROCK_FILE="${NAME}_0.1_amd64.rock" + IMAGE="${NAME}:0.1" + + run_rockcraft init --name flask-extension --profile flask-framework + sed -i "s/name: .*/name: ${NAME}/g" rockcraft.yaml + if [ "${SCENARIO}" = "bare" ]; then + sed -i "s/base: .*/base: ${SCENARIO}\nbuild-base: ubuntu@22.04/g" rockcraft.yaml + else + sed -i "s/base: .*/base: ${SCENARIO//-/@}/g" rockcraft.yaml + fi + run_rockcraft pack + + test -f "${ROCK_FILE}" + # test ! -d work + + # Ensure docker does not have this container image + # docker rmi --force "${IMAGE}" + # Install image + sudo rockcraft.skopeo --insecure-policy copy "oci-archive:${ROCK_FILE}" "docker-daemon:${IMAGE}" + # Ensure image exists + docker images "${IMAGE}" | MATCH "${NAME}" + + # ensure container doesn't exist + # docker rm -f "${NAME}-container" + + # test that gunicorn is installed and config file is correct + docker run --rm --entrypoint /bin/python3 "${IMAGE}" -m gunicorn --chdir /flask/app --check-config app:app + + # test that gevent is installed + docker run --rm --entrypoint /bin/python3 "${IMAGE}" -c "import gevent;print(f'gevent: {gevent.__version__}')" + + # test that pebble service uses gevent + docker run --rm "${IMAGE}" plan | grep gevent + + # test the default flask service + docker run --name "${NAME}-container" -d -p 8138:8000 "${IMAGE}" + retry -n 5 --wait 2 curl localhost:8138 + [ "$(curl -sSf localhost:8138)" == "ok" ] + + # test the async flask service + NUM_REQUESTS=15 + URL='localhost:8138/io' + ASYNC_RESULT='TRUE' + + echo "Firing $NUM_REQUESTS requests to $URL..." + + overall_start_time=$(date +%s) + + for i in $(seq 1 $NUM_REQUESTS); do + ( + start_time=$(date +%s) + echo "Request $i start time: $start_time" + + curl -s "$URL" + + end_time=$(date +%s) + pass_time=$((end_time - start_time)) + echo "Request $i end time: $end_time == $pass_time" + echo "Request $i end time: $end_time == $pass_time" + ) & + done + + wait + end_time=$(date +%s) + overall_passtime=$((end_time - overall_start_time)) + echo "Total pass time: $overall_passtime" + if [ $((3 < overall_passtime)) -eq 1 ]; then + echo "Error!" + ASYNC_RESULT='FALSE' + exit 2 + fi + [ "$ASYNC_RESULT" == 'TRUE' ] + + # remove the async flask container + docker rm "${NAME}-container" -f + + # test the sync flask service + docker run --name "${NAME}-container" -d -p 8138:8000 "${IMAGE}" --args flask sync + retry -n 5 --wait 2 curl localhost:8138 + [ "$(curl -sSf localhost:8138)" == "ok" ] + + NUM_REQUESTS=5 + URL='localhost:8138/io' + SYNC_RESULT='TRUE' + + echo "Firing $NUM_REQUESTS requests to $URL..." + + overall_start_time=$(date +%s) + + for i in $(seq 1 $NUM_REQUESTS); do + ( + start_time=$(date +%s) + echo "Request $i start time: $start_time" + + curl -s "$URL" + + end_time=$(date +%s) + pass_time=$((end_time - start_time)) + echo "Request $i end time: $end_time == $pass_time" + echo "Request $i end time: $end_time == $pass_time" + ) & + done + + wait + end_time=$(date +%s) + overall_passtime=$((end_time - overall_start_time)) + echo "Total pass time: $overall_passtime" + if [ $((9 > overall_passtime)) -eq 1 ]; then + echo "Error!" + SYNC_RESULT='FALSE' + exit 2 + fi + [ "$SYNC_RESULT" == 'TRUE' ] +restore: | + NAME="flask-${SCENARIO//./-}" + docker stop "${NAME}-container" + docker rm "${NAME}-container" + rm -f "*.rock" rockcraft.yaml + docker system prune -a -f diff --git a/tests/unit/extensions/test_gunicorn.py b/tests/unit/extensions/test_gunicorn.py index 8b7b545a1..e78a8f5c1 100644 --- a/tests/unit/extensions/test_gunicorn.py +++ b/tests/unit/extensions/test_gunicorn.py @@ -51,8 +51,14 @@ def django_input_yaml_fixture(): @pytest.mark.usefixtures("flask_extension") @pytest.mark.parametrize("packages", ["other\nflask", "flask", "Flask", " flask == 99"]) -def test_flask_extension_default(tmp_path, flask_input_yaml, packages): - (tmp_path / "requirements.txt").write_text(packages) +@pytest.mark.parametrize( + ("async_package", "expected_worker"), [("gevent", "gevent"), ("", "sync")] +) +def test_flask_extension_default( + tmp_path, flask_input_yaml, packages, async_package, expected_worker +): + full_packages = "\n".join([packages, async_package]) + (tmp_path / "requirements.txt").write_text(full_packages) (tmp_path / "app.py").write_text("app = object()") (tmp_path / "static").mkdir() (tmp_path / "node_modules").mkdir() @@ -107,7 +113,7 @@ def test_flask_extension_default(tmp_path, flask_input_yaml, packages): "flask": { "after": ["statsd-exporter"], "command": "/bin/python3 -m gunicorn -c " - "/flask/gunicorn.conf.py app:app", + f"/flask/gunicorn.conf.py app:app -k [ {expected_worker} ]", "override": "replace", "startup": "enabled", "user": "_daemon_", @@ -231,7 +237,7 @@ def test_flask_framework_add_service(tmp_path, flask_input_yaml): assert applied["services"] == { "flask": { "after": ["statsd-exporter"], - "command": "/bin/python3 -m gunicorn -c /flask/gunicorn.conf.py app:app", + "command": "/bin/python3 -m gunicorn -c /flask/gunicorn.conf.py app:app -k [ sync ]", "override": "replace", "startup": "enabled", "user": "_daemon_", @@ -447,8 +453,13 @@ def test_flask_extension_flask_service_override_disable_wsgi_path_check(tmp_path @pytest.mark.usefixtures("django_extension") -def test_django_extension_default(tmp_path, django_input_yaml): - (tmp_path / "requirements.txt").write_text("django") +@pytest.mark.parametrize( + ("packages", "expected_worker"), [("Django\ngevent", "gevent"), ("Django", "sync")] +) +def test_django_extension_default( + tmp_path, django_input_yaml, packages, expected_worker +): + (tmp_path / "requirements.txt").write_text(packages) (tmp_path / "test").mkdir() (tmp_path / "foo_bar" / "foo_bar").mkdir(parents=True) (tmp_path / "foo_bar" / "foo_bar" / "wsgi.py").write_text("application = object()") @@ -498,7 +509,7 @@ def test_django_extension_default(tmp_path, django_input_yaml): "services": { "django": { "after": ["statsd-exporter"], - "command": "/bin/python3 -m gunicorn -c /django/gunicorn.conf.py foo_bar.wsgi:application", + "command": f"/bin/python3 -m gunicorn -c /django/gunicorn.conf.py foo_bar.wsgi:application -k [ {expected_worker} ]", "override": "replace", "startup": "enabled", "user": "_daemon_", diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 9bccb5d7d..9cf705b11 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -17,6 +17,7 @@ import sys import textwrap from pathlib import Path +from shutil import copytree from unittest.mock import DEFAULT, call import pytest @@ -26,6 +27,8 @@ from rockcraft.application import APP_METADATA, Rockcraft from rockcraft.models import project +DATA_DIR = pathlib.Path(__file__).parent.parent / "data" + def test_run_pack_services(mocker, monkeypatch, tmp_path): # Pretend it's running inside the managed instance @@ -128,8 +131,7 @@ def test_run_init_fallback_name(mocker, new_dir, monkeypatch): def test_run_init_flask(mocker, emitter, monkeypatch, new_dir, tmp_path): - (new_dir / "requirements.txt").write_text("Flask", encoding="utf-8") - (new_dir / "app.py").write_text("app = object()", encoding="utf-8") + copytree(Path(f"{DATA_DIR}/flask"), tmp_path, dirs_exist_ok=True) mocker.patch.object( sys, @@ -146,86 +148,47 @@ def test_run_init_flask(mocker, emitter, monkeypatch, new_dir, tmp_path): assert len(rock_project_yaml["summary"]) < 80 assert len(rock_project_yaml["description"].split()) < 100 - assert rockcraft_yaml_path.read_text() == textwrap.dedent( - f"""\ - name: test-name - # see {versioned_url}/explanation/bases/ - # for more information about bases and using 'bare' bases for chiselled rocks - base: ubuntu@22.04 # the base environment for this Flask application - version: '0.1' # just for humans. Semantic versioning is recommended - summary: A summary of your Flask application # 79 char long summary - description: | - This is test-name's description. You have a paragraph or two to tell the - most important story about it. Keep it under 100 words though, - we live in tweetspace and your description wants to look good in the - container registries out there. - # the platforms this rock should be built on and run on. - # you can check your architecture with `dpkg --print-architecture` - platforms: - amd64: - # arm64: - # ppc64el: - # s390x: - - # to ensure the flask-framework extension works properly, your Flask application - # should have an `app.py` file with an `app` object as the WSGI entrypoint. - # a `requirements.txt` file with at least the flask package should also exist. - # see {versioned_url}/reference/extensions/flask-framework - # for more information. - extensions: - - flask-framework - - # uncomment the sections you need and adjust according to your requirements. - # parts: # you need to uncomment this line to add or update any part. - - # flask-framework/install-app: - # prime: - # # by default, only the files in app/, templates/, static/, migrate, migrate.sh, - # # migrate.py and app.py are copied into the image. You can modify the list - # # below to override the default list and include or exclude specific - # # files/directories in your project. - # # note: prefix each entry with "flask/app/" followed by the local path. - # - flask/app/.env - # - flask/app/app.py - # - flask/app/webapp - # - flask/app/templates - # - flask/app/static - - # you may need Ubuntu packages to build a python dependency. Add them here if necessary. - # flask-framework/dependencies: - # build-packages: - # # for example, if you need pkg-config and libxmlsec1-dev to build one - # # of your packages: - # - pkg-config - # - libxmlsec1-dev - - # you can add package slices or Debian packages to the image. - # package slices are subsets of Debian packages, which result - # in smaller and more secure images. - # see {versioned_url}/explanation/chisel/ - - # add this part if you want to add packages slices to your image. - # you can find a list of packages slices at https://github.com/canonical/chisel-releases - # runtime-slices: - # plugin: nil - # stage-packages: - # # list the required package slices for your flask application below. - # # for example, for the slice libs of libpq5: - # - libpq5_libs - - # if you want to add a Debian package to your image, add the next part - # runtime-debs: - # plugin: nil - # stage-packages: - # # list required Debian packages for your flask application below. - # - libpq5 - """ - ) + expected_rockcraft_yaml_path = Path(tmp_path / "expected_rockcraft.yaml") + assert rockcraft_yaml_path.read_text() == expected_rockcraft_yaml_path.read_text() + emitter.assert_message( textwrap.dedent( f"""\ Go to {versioned_url}/reference/extensions/flask-framework to read more about the 'flask-framework' profile.""" ) ) + # apply extension logic to make sure `rockcraft.yaml` file is proper + monkeypatch.setenv("ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS", "0") + project.Project.unmarshal(extensions.apply_extensions(tmp_path, rock_project_yaml)) + + +def test_run_init_django(mocker, emitter, monkeypatch, new_dir, tmp_path): + copytree(Path(f"{DATA_DIR}/django"), tmp_path, tmp_path, dirs_exist_ok=True) + + mocker.patch.object( + sys, + "argv", + ["rockcraft", "init", "--profile=django-framework", "--name", "test-name"], + ) + + cli.run() + + versioned_url = APP_METADATA.versioned_docs_url + + rockcraft_yaml_path = Path(tmp_path / "rockcraft.yaml") + expected_rockcraft_yaml_path = Path(tmp_path / "expected_rockcraft.yaml") + rock_project_yaml = yaml.safe_load(rockcraft_yaml_path.read_text()) + + assert len(rock_project_yaml["summary"]) < 80 + assert len(rock_project_yaml["description"].split()) < 100 + assert rockcraft_yaml_path.read_text() == expected_rockcraft_yaml_path.read_text() + + emitter.assert_message( + textwrap.dedent( + f"""\ + Go to {versioned_url}/reference/extensions/django-framework to read more about the 'django-framework' profile.""" + ) + ) + # apply extension logic to make sure `rockcraft.yaml` file is proper monkeypatch.setenv("ROCKCRAFT_ENABLE_EXPERIMENTAL_EXTENSIONS", "0") project.Project.unmarshal(extensions.apply_extensions(tmp_path, rock_project_yaml))