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

Allow more Flask entrypoints #733

Open
wants to merge 10 commits into
base: main
Choose a base branch
from
11 changes: 8 additions & 3 deletions docs/reference/extensions/flask-framework.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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/install-app`` > ``prime``
=======================================================
Expand Down
15 changes: 12 additions & 3 deletions rockcraft/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
41 changes: 39 additions & 2 deletions rockcraft/extensions/_python_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
51 changes: 22 additions & 29 deletions rockcraft/extensions/fastapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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."""
Expand Down
101 changes: 76 additions & 25 deletions rockcraft/extensions/gunicorn.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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_",
},
Expand All @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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",
)
)
}

Expand All @@ -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."""
Expand All @@ -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",
Expand All @@ -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"
Expand All @@ -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(
Expand All @@ -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."""
Expand Down
Loading
Loading