diff --git a/docs/reference/extensions/flask-framework.rst b/docs/reference/extensions/flask-framework.rst index 319c6f519..0bf7e2454 100644 --- a/docs/reference/extensions/flask-framework.rst +++ b/docs/reference/extensions/flask-framework.rst @@ -23,9 +23,14 @@ There are 2 requirements to be able to use the ``flask-framework`` extension: 1. There must be a ``requirements.txt`` file in the root of the project with ``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`` +2. The project must include a WSGI app in a variable called ``app`` in one of + the following files relative to the project root (in order of priority): + + * ``app.py`` + * ``main.py`` + * ``__init__.py``, ``app.py`` or ``main.py`` within the ``app`` or ``src`` + directory or within a directory with the name of the rock as declared in + ``rockcraft.yaml``. ``parts`` > ``flask-framework/dependencies`` > ``stage-packages`` ================================================================= diff --git a/rockcraft/commands/init.py b/rockcraft/commands/init.py index 5dcdac279..e347d39f8 100644 --- a/rockcraft/commands/init.py +++ b/rockcraft/commands/init.py @@ -107,7 +107,15 @@ class InitCommand(AppCommand): # 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. + # should have a WSGI entrypoint variable named `app`. This variable has to be defined + # in one of the following places: + # 1. `app.py` file in the base directory + # 2. `main.py` file in the base directory + # 3. In any of the following directories, the extension will look for the variable + # in the files __init__.py, app.py or main.py: + # a. `app` directory. + # b. `src` directory. + # c. A directory with the same name as the project. # a `requirements.txt` file with at least the flask package should also exist. # see {versioned_url}/reference/extensions/flask-framework # for more information. @@ -298,11 +306,12 @@ class InitCommand(AppCommand): # ppc64el: # s390x: - # to ensure the FastAPI-framework extension works properly, your FastAPI application + # to ensure the fastapi-framework extension works properly, your FastAPI application # should have an ASGI entrypoint variable named `app`. This variable has to be defined # in one of the following places: # 1. `app.py` file in the base directory - # 2. In any of the following directories, the extension will look for the variable + # 2. `main.py` file in the base directory + # 3. In any of the following directories, the extension will look for the variable # in the files __init__.py, app.py or main.py: # a. `app` directory. # b. `src` directory. diff --git a/rockcraft/extensions/_python_utils.py b/rockcraft/extensions/_python_utils.py index b4a730bc6..a04ae3c1c 100644 --- a/rockcraft/extensions/_python_utils.py +++ b/rockcraft/extensions/_python_utils.py @@ -17,10 +17,47 @@ """Utils for Python based extensions.""" import ast -import pathlib +from pathlib import Path +from typing import Iterable -def has_global_variable(source_file: pathlib.Path, variable_name: str) -> bool: +def find_file_with_variable( + base_dir: Path, python_paths: Iterable[Path], variable_name: str +) -> Path: + """Find Python file that contains a given global variable. + + Given a base_dir, several paths for Python files, and a variable_name, return the first + path that contains variable_name as a global variable. If the variable is not found, + raise FileNotFoundError. + """ + for python_path in python_paths: + full_path = base_dir / python_path + if full_path.exists() and has_global_variable(full_path, variable_name): + return python_path + raise FileNotFoundError(f"File not found with variable {variable_name}") + + +def find_entrypoint_with_variable( + base_dir: Path, python_paths: Iterable[Path], variable_name: str +) -> str: + """Find Python file entrypoint for a global variable. + + Given a base_dir, several paths for Python files, and a variable_name, return the first + entrypoing that contains variable_name as a global variable in the format path:variable_name, + following ASGI and WSGI specification. If the variable is not found, raise FileNotFoundError. + """ + python_path = find_file_with_variable(base_dir, python_paths, variable_name) + return ( + ".".join( + part.removesuffix(".py") + for part in python_path.parts + if part != "__init__.py" + ) + + f":{variable_name}" + ) + + +def has_global_variable(source_file: Path, variable_name: str) -> bool: """Check whether the given Python source code has a global variable defined.""" tree = ast.parse(source_file.read_text(encoding="utf-8"), filename=source_file) for node in ast.iter_child_nodes(tree): diff --git a/rockcraft/extensions/fastapi.py b/rockcraft/extensions/fastapi.py index be7f9876a..e82a21445 100644 --- a/rockcraft/extensions/fastapi.py +++ b/rockcraft/extensions/fastapi.py @@ -18,15 +18,15 @@ import fnmatch import os -import pathlib import posixpath import re -from typing import Any +from pathlib import Path +from typing import Any, Iterable from overrides import override from ..errors import ExtensionError -from ._python_utils import has_global_variable +from ._python_utils import find_entrypoint_with_variable, find_file_with_variable from .extension import Extension @@ -182,44 +182,37 @@ def _app_prime(self) -> list[str]: ) return user_prime - def _asgi_path(self) -> str: - asgi_location = self._find_asgi_location() - return ( - ".".join( - part.removesuffix(".py") - for part in asgi_location.parts - if part != "__init__.py" - ) - + ":app" - ) - - def _find_asgi_location(self) -> pathlib.Path: - """Return the path of the asgi entrypoint file. + def _asgi_locations(self) -> Iterable[Path]: + """Return the possible locations for the ASGI entrypoint. It will look for an `app` global variable in the following places: 1. `app.py`. - 2. Inside the directories `app`, `src` and rockcraft name, in the files + 2. `main.py`. + 3. Inside the directories `app`, `src` and rockcraft name, in the files `__init__.py`, `app.py` or `main.py`. - - It will return the first instance found or raise FileNotFoundError. """ - places_to_look = ( - (".", "app.py"), - (".", "main.py"), + return ( + Path(".", "app.py"), + Path(".", "main.py"), *( - (src_dir, src_file) + Path(src_dir, src_file) for src_dir in ("app", "src", self.name) for src_file in ("__init__.py", "app.py", "main.py") ), ) - for src_dir, src_file in places_to_look: - full_path = self.project_root / src_dir / src_file - if full_path.exists(): - if has_global_variable(full_path, "app"): - return pathlib.Path(src_dir, src_file) + def _asgi_path(self) -> str: + """Return the asgi path of the asgi application.""" + return find_entrypoint_with_variable( + self.project_root, self._asgi_locations(), "app" + ) - raise FileNotFoundError("ASGI entrypoint not found") + def _find_asgi_location(self) -> Path: + """Return the path of the asgi entrypoint file. + + It will return the first instance found or raise FileNotFoundError. + """ + return find_file_with_variable(self.project_root, self._asgi_locations(), "app") def _check_project(self) -> None: """Ensure this extension can apply to the current rockcraft project.""" diff --git a/rockcraft/extensions/gunicorn.py b/rockcraft/extensions/gunicorn.py index 890a6af29..e34b7e383 100644 --- a/rockcraft/extensions/gunicorn.py +++ b/rockcraft/extensions/gunicorn.py @@ -20,12 +20,17 @@ import os.path import posixpath import re -from typing import Any +from pathlib import Path +from typing import Any, Iterable from overrides import override from ..errors import ExtensionError -from ._python_utils import has_global_variable +from ._python_utils import ( + find_entrypoint_with_variable, + find_file_with_variable, + has_global_variable, +) from .extension import Extension, get_extensions_data_dir @@ -62,6 +67,11 @@ def check_project(self) -> None: def gen_install_app_part(self) -> dict[str, Any]: """Generate the content of *-framework/install-app part.""" + @property + def name(self) -> str: + """Return the normalized name of the rockcraft project.""" + return self.yaml_data["name"].replace("-", "_").lower() + def _gen_parts(self) -> dict: """Generate the parts associated with this extension.""" data_dir = get_extensions_data_dir() @@ -130,7 +140,6 @@ 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}", "after": ["statsd-exporter"], "user": "_daemon_", }, @@ -147,6 +156,15 @@ def get_root_snippet(self) -> dict[str, Any]: }, }, } + # It the user has overridden the service command, do not try to add it. + if ( + not self.yaml_data.get("services", {}) + .get(self.framework, {}) + .get("command") + ): + snippet["services"][self.framework][ + "command" + ] = f"/bin/python3 -m gunicorn -c /{self.framework}/gunicorn.conf.py {self.wsgi_path}" snippet["parts"] = self._gen_parts() return snippet @@ -168,7 +186,9 @@ class FlaskFramework(_GunicornBase): @override def wsgi_path(self) -> str: """Return the wsgi path of the wsgi application.""" - return "app:app" + return find_entrypoint_with_variable( + self.project_root, self._wsgi_locations(), "app" + ) @property @override @@ -197,7 +217,14 @@ def gen_install_app_part(self) -> dict[str, Any]: for f in source_files if not any( fnmatch.fnmatch(f, p) - for p in ("node_modules", ".git", ".yarn", "*.rock") + for p in ( + "node_modules", + ".git", + ".yarn", + "*.rock", + # requirements.txt file is used for dependencies, not install + "requirements.txt", + ) ) } @@ -209,6 +236,25 @@ def gen_install_app_part(self) -> dict[str, Any]: "prime": self._app_prime, } + def _wsgi_locations(self) -> Iterable[Path]: + """Return the possible locations for the WSGI entrypoint. + + It will look for an `app` global variable in the following places: + 1. `app.py`. + 2. `main.py`. + 3. Inside the directories `app`, `src` and rockcraft name, in the files + `__init__.py`, `app.py` or `main.py`. + """ + return ( + Path(".", "app.py"), + Path(".", "main.py"), + *( + Path(src_dir, src_file) + for src_dir in ("app", "src", self.name) + for src_file in ("__init__.py", "app.py", "main.py") + ), + ) + @property def _app_prime(self) -> list[str]: """Return the prime list for the Flask project.""" @@ -227,6 +273,7 @@ def _app_prime(self) -> list[str]: if not user_prime: user_prime = [ f"flask/app/{f}" + # app and app.py are maintained for backward compatibility for f in ( "app", "app.py", @@ -238,27 +285,32 @@ def _app_prime(self) -> list[str]: ) if (self.project_root / f).exists() ] + if ( + not self.yaml_data.get("services", {}) + .get(self.framework, {}) + .get("command") + ): + # add the entrypoint only if user prime is not exclude mode + if not (user_prime and user_prime[0] and user_prime[0][0] == "-"): + new_prime = "flask/app/" + self._find_wsgi_location().parts[0] + if new_prime not in user_prime: + user_prime.append(new_prime) return user_prime def _wsgi_path_error_messages(self) -> list[str]: """Ensure the extension can infer the WSGI path of the Flask application.""" - app_file = self.project_root / "app.py" - if not app_file.exists(): - return [ - "flask application can not be imported from app:app, no app.py file found in the project root." - ] try: - has_app = has_global_variable(app_file, "app") - except SyntaxError as err: - return [f"error parsing app.py: {err.msg}"] - - if not has_app: - return [ - "flask application can not be imported from app:app in app.py in the project root." - ] - + self._find_wsgi_location() + except FileNotFoundError: + return ["missing WSGI entrypoint in default search locations."] + except SyntaxError as e: + return [f"Syntax error in python file in WSGI search path: {e}"] return [] + def _find_wsgi_location(self) -> Path: + """Return the path of the first wsgi entrypoint file or raise FileNotFoundError.""" + return find_file_with_variable(self.project_root, self._wsgi_locations(), "app") + def _requirements_txt_error_messages(self) -> list[str]: """Ensure the requirements.txt file is correct.""" requirements_file = self.project_root / "requirements.txt" @@ -277,7 +329,11 @@ def _requirements_txt_error_messages(self) -> list[str]: 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"): + if ( + not self.yaml_data.get("services", {}) + .get(self.framework, {}) + .get("command") + ): error_messages += self._wsgi_path_error_messages() if error_messages: raise ExtensionError( @@ -290,11 +346,6 @@ def check_project(self) -> None: class DjangoFramework(_GunicornBase): """An extension for constructing Python applications based on the Django framework.""" - @property - def name(self) -> str: - """Return the normalized name of the rockcraft project.""" - return self.yaml_data["name"].replace("-", "_").lower() - @property def default_wsgi_path(self) -> str: """Return the default wsgi path for the Django project.""" diff --git a/tests/unit/extensions/test_gunicorn.py b/tests/unit/extensions/test_gunicorn.py index 8b7b545a1..761ad3419 100644 --- a/tests/unit/extensions/test_gunicorn.py +++ b/tests/unit/extensions/test_gunicorn.py @@ -28,6 +28,7 @@ def flask_extension(mock_extensions): @pytest.fixture(name="flask_input_yaml") def flask_input_yaml_fixture(): return { + "name": "foo-bar", "base": "ubuntu@22.04", "platforms": {"amd64": {}}, "extensions": ["flask-framework"], @@ -64,6 +65,7 @@ def test_flask_extension_default(tmp_path, flask_input_yaml, packages): assert source[-len(suffix) :].replace("\\", "/") == suffix assert applied == { + "name": "foo-bar", "base": "ubuntu@22.04", "parts": { "flask-framework/config-files": { @@ -127,6 +129,85 @@ def test_flask_extension_default(tmp_path, flask_input_yaml, packages): } +@pytest.mark.parametrize( + "files,organize,command", + [ + ( + {"app.py": "app = object()"}, + {"app.py": "flask/app/app.py"}, + "/bin/python3 -m gunicorn -c /flask/gunicorn.conf.py app:app", + ), + ( + {"app/__init__.py": "app = object()"}, + {"app": "flask/app/app"}, + "/bin/python3 -m gunicorn -c /flask/gunicorn.conf.py app:app", + ), + ( + {"app/__init__.py": "from .app import app"}, + {"app": "flask/app/app"}, + "/bin/python3 -m gunicorn -c /flask/gunicorn.conf.py app:app", + ), + ( + {"app/app.py": "app = object()"}, + {"app": "flask/app/app"}, + "/bin/python3 -m gunicorn -c /flask/gunicorn.conf.py app.app:app", + ), + ( + {"app/main.py": "app = object()"}, + {"app": "flask/app/app"}, + "/bin/python3 -m gunicorn -c /flask/gunicorn.conf.py app.main:app", + ), + ( + {"app/app.py": "app = object()", "app/__init__.py": "from .app import app"}, + {"app": "flask/app/app"}, + "/bin/python3 -m gunicorn -c /flask/gunicorn.conf.py app:app", + ), + ( + {"src/app.py": "app = object()"}, + {"src": "flask/app/src"}, + "/bin/python3 -m gunicorn -c /flask/gunicorn.conf.py src.app:app", + ), + ( + {"foo_bar/app.py": "app = object()"}, + {"foo_bar": "flask/app/foo_bar"}, + "/bin/python3 -m gunicorn -c /flask/gunicorn.conf.py foo_bar.app:app", + ), + ( + {"app.py": "app = object()", "foo_bar/app.py": "app = object()"}, + {"app.py": "flask/app/app.py"}, + "/bin/python3 -m gunicorn -c /flask/gunicorn.conf.py app:app", + ), + pytest.param( + {"main.py": "app = object()", "src/app.py": "app = object()"}, + {"main.py": "flask/app/main.py"}, + "/bin/python3 -m gunicorn -c /flask/gunicorn.conf.py main:app", + id="With two entrypoints, take the first one", + ), + pytest.param( + {"src/app.py": "app = object()", "migrate.sh": "", "unknown": ""}, + {"src": "flask/app/src", "migrate.sh": "flask/app/migrate.sh"}, + "/bin/python3 -m gunicorn -c /flask/gunicorn.conf.py src.app:app", + id="Include other files beside the entrypoint", + ), + ], +) +@pytest.mark.usefixtures("flask_extension") +def test_flask_extension_wsgi_entrypoints( + tmp_path, flask_input_yaml, files, organize, command +): + (tmp_path / "requirements.txt").write_text("flask") + for file_path, content in files.items(): + (tmp_path / file_path).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / file_path).write_text(content) + applied = extensions.apply_extensions(tmp_path, flask_input_yaml) + install_app_part = applied["parts"]["flask-framework/install-app"] + assert install_app_part["organize"] == organize + assert applied["parts"]["flask-framework/install-app"]["stage"] == list( + install_app_part["organize"].values() + ) + assert applied["services"]["flask"]["command"] == command + + @pytest.mark.usefixtures("flask_extension") def test_flask_extension_prime_override(tmp_path, flask_input_yaml): (tmp_path / "requirements.txt").write_text("flask") @@ -138,7 +219,6 @@ def test_flask_extension_prime_override(tmp_path, flask_input_yaml): "flask-framework/install-app": { "prime": [ "flask/app/app.py", - "flask/app/requirements.txt", "flask/app/static", ] } @@ -147,17 +227,14 @@ def test_flask_extension_prime_override(tmp_path, flask_input_yaml): install_app_part = applied["parts"]["flask-framework/install-app"] assert install_app_part["prime"] == [ "flask/app/app.py", - "flask/app/requirements.txt", "flask/app/static", ] assert install_app_part["organize"] == { "app.py": "flask/app/app.py", - "requirements.txt": "flask/app/requirements.txt", "static": "flask/app/static", } assert install_app_part["stage"] == [ "flask/app/app.py", - "flask/app/requirements.txt", "flask/app/static", ] @@ -182,14 +259,12 @@ def test_flask_framework_exclude_prime(tmp_path, flask_input_yaml): assert install_app_part["prime"] == ["- flask/app/test"] assert install_app_part["organize"] == { "app.py": "flask/app/app.py", - "requirements.txt": "flask/app/requirements.txt", "static": "flask/app/static", "test": "flask/app/test", "webapp": "flask/app/webapp", } assert install_app_part["stage"] == [ "flask/app/app.py", - "flask/app/requirements.txt", "flask/app/static", "flask/app/test", "flask/app/webapp", @@ -286,6 +361,7 @@ def test_flask_extension_bare(tmp_path): (tmp_path / "requirements.txt").write_text("flask") (tmp_path / "app.py").write_text("app = object()") flask_input_yaml = { + "name": "foo-bar", "extensions": ["flask-framework"], "base": "bare", "build-base": "ubuntu@22.04", @@ -312,6 +388,7 @@ def test_flask_extension_bare(tmp_path): def test_flask_extension_no_requirements_txt_error(tmp_path): (tmp_path / "app.py").write_text("app = object()") flask_input_yaml = { + "name": "foo-bar", "extensions": ["flask-framework"], "base": "bare", "build-base": "ubuntu@22.04", @@ -330,6 +407,7 @@ def test_flask_extension_requirements_txt_no_flask_error(tmp_path): (tmp_path / "app.py").write_text("app = object()") (tmp_path / "requirements.txt").write_text("") flask_input_yaml = { + "name": "foo-bar", "extensions": ["flask-framework"], "base": "bare", "build-base": "ubuntu@22.04", @@ -357,6 +435,7 @@ def test_flask_extension_bad_app_py(tmp_path): (tmp_path / "app.py").write_text(bad_code) (tmp_path / "requirements.txt").write_text("flask") flask_input_yaml = { + "name": "foo-bar", "extensions": ["flask-framework"], "base": "bare", "build-base": "ubuntu@22.04", @@ -365,15 +444,14 @@ def test_flask_extension_bad_app_py(tmp_path): with pytest.raises(ExtensionError) as exc: extensions.apply_extensions(tmp_path, flask_input_yaml) - expected = ( - "- error parsing app.py: unterminated string literal (detected at line 5)" - ) + expected = "- Syntax error in python file in WSGI search path: unterminated string literal (detected at line 5) (app.py, line 5)" assert str(exc.value) == expected @pytest.mark.usefixtures("flask_extension") def test_flask_extension_no_requirements_txt_no_app_py_error(tmp_path): flask_input_yaml = { + "name": "foo-bar", "extensions": ["flask-framework"], "base": "bare", "build-base": "ubuntu@22.04", @@ -383,7 +461,7 @@ def test_flask_extension_no_requirements_txt_no_app_py_error(tmp_path): extensions.apply_extensions(tmp_path, flask_input_yaml) assert str(exc.value) == ( "- missing a requirements.txt file. The flask-framework extension requires this file with 'flask' specified as a dependency.\n" - "- flask application can not be imported from app:app, no app.py file found in the project root." + "- missing WSGI entrypoint in default search locations." ) @@ -392,6 +470,7 @@ def test_flask_extension_incorrect_prime_prefix_error(tmp_path): (tmp_path / "requirements.txt").write_text("flask") (tmp_path / "app.py").write_text("app = object()") flask_input_yaml = { + "name": "foo-bar", "extensions": ["flask-framework"], "base": "bare", "build-base": "ubuntu@22.04", @@ -408,6 +487,7 @@ def test_flask_extension_incorrect_prime_prefix_error(tmp_path): @pytest.mark.usefixtures("flask_extension") def test_flask_extension_incorrect_wsgi_path_error(tmp_path): flask_input_yaml = { + "name": "foo-bar", "extensions": ["flask-framework"], "base": "bare", "build-base": "ubuntu@22.04", @@ -418,13 +498,13 @@ def test_flask_extension_incorrect_wsgi_path_error(tmp_path): with pytest.raises(ExtensionError) as exc: extensions.apply_extensions(tmp_path, flask_input_yaml) - assert "app:app" in str(exc) + assert "missing WSGI entrypoint" in str(exc) (tmp_path / "app.py").write_text("flask") with pytest.raises(ExtensionError) as exc: extensions.apply_extensions(tmp_path, flask_input_yaml) - assert "app:app" in str(exc) + assert "missing WSGI entrypoint" in str(exc) @pytest.mark.usefixtures("flask_extension") @@ -432,6 +512,7 @@ def test_flask_extension_flask_service_override_disable_wsgi_path_check(tmp_path (tmp_path / "requirements.txt").write_text("flask") flask_input_yaml = { + "name": "foo-bar", "extensions": ["flask-framework"], "base": "bare", "build-base": "ubuntu@22.04", diff --git a/tests/unit/test_cli.py b/tests/unit/test_cli.py index 81fb6991d..dd08956c3 100644 --- a/tests/unit/test_cli.py +++ b/tests/unit/test_cli.py @@ -159,7 +159,15 @@ def test_run_init_flask(mocker, emitter, monkeypatch, new_dir, tmp_path): # 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. + # should have a WSGI entrypoint variable named `app`. This variable has to be defined + # in one of the following places: + # 1. `app.py` file in the base directory + # 2. `main.py` file in the base directory + # 3. In any of the following directories, the extension will look for the variable + # in the files __init__.py, app.py or main.py: + # a. `app` directory. + # b. `src` directory. + # c. A directory with the same name as the project. # a `requirements.txt` file with at least the flask package should also exist. # see {versioned_url}/reference/extensions/flask-framework # for more information.