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

Adding celery scaffold to the project. #36

Merged
merged 1 commit into from
Apr 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 2 additions & 0 deletions Pipfile
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,5 @@ flask-container-scaffold = {path = ".",extras = ["devbase","test","docs","dist"]
[packages]
flask-container-scaffold = {path = ".",editable = true}

[celery]
flask-container-scaffold = {file = ".", editable = true, extras = ["celery"]}
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,51 @@ example:
},
})

### CeleryScaffold

This class has all of the same support as the above AppScaffold and takes
the same parameters. Each CeleryScaffold instance has a flask_app and celery_app
attribute that can be used in your project. More information about celery can
be found [here](https://docs.celeryq.dev/en/stable/getting-started/introduction.html).
Information on integrating celery with flask can be found in flask's
[documentation](https://flask.palletsprojects.com/en/2.3.x/patterns/celery/).


#### Installation

pip install flask-container-scaffold['celery']

or

pipenv install --categories celery

#### Basic Usage

celery_scaffold = CeleryScaffold(name=__name__, config=config)
flask_app = celery_scaffold.flask_app
celery_app = celery_scaffold.celery_app

#### Basic Configuration

All configuration is done via a 'CELERY' key in a configuration dictionary. The
'CELERY' element itself is a dictionary of configuration items. More details on the
available configuration items for celery can be found [here](https://docs.celeryq.dev/en/stable/userguide/configuration.html).
Below is a basic example in yaml format that uses a local rabbitmq broker, json serialization, and no result backend.

```
---

CELERY:
broker: "pyamqp://[email protected]//"
result_persistent: False
task_serializer: "json"
accept_content:
- "json" # Ignore other content
result_serializer: "json"
result_expires: "300"
broker_connection_retry_on_startup: 'False'
```

### Using the parse_input method

This method is used to validate incoming data against a pydantic model. A
Expand Down
3 changes: 3 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ install_requires =
toolchest

[options.extras_require]
celery =
celery
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(praise) I really like how self-contained the additions are, here and in the Pipfile, install instructions, etc. It works really neatly.


devbase =
tox

Expand Down
96 changes: 8 additions & 88 deletions src/flask_container_scaffold/app_scaffold.py
Original file line number Diff line number Diff line change
@@ -1,98 +1,18 @@
import os
from flask_container_scaffold.base_scaffold import BaseScaffold

from flask import Flask

from flask_container_scaffold.app_configurator import AppConfigurator


class AppScaffold(object):
class AppScaffold(BaseScaffold):

def __init__(self, app=None,
name=__name__, config=None,
settings_required=False,
instance_path=None,
instance_relative_config=True):
"""
This class provides a way to dynamically configure a Flask application.

:param obj app: An existing Flask application, if passed, otherwise we
will create a new one
:param str name: The name of the application, defaults to __name__.
:param dict config: A dict of configuration details. This can include
standard Flask configuration keys, like 'TESTING', or
'CUSTOM_SETTINGS' (which can be a string referencing a file with custom
configuration, or a dictionary containing any values your application
may need) to make them available to the application during runtime
:param bool settings_required: Whether your app requires certain
settings be specified in a settings.cfg file
:param str instance_path: Passthrough parameter to flask. An
alternative instance path for the application. By default
the folder 'instance' next to the package or module is
assumed to be the instance path.
:param bool instance_relative_config: Passthrough parameter to flask.
If set to True relative filenames for loading the config
are assumed to be relative to the instance path instead of
the application root.

"""
# TODO: Consider taking **kwargs here, so we can automatically support
# all params the flask object takes, and just pass them through. Keep
# the ones we already have, as they are needed for the current code to
# work.
Flask.jinja_options = dict(Flask.jinja_options, trim_blocks=True,
lstrip_blocks=True)
self.app = (app or
Flask(name,
instance_relative_config=instance_relative_config,
instance_path=instance_path))
self.config = config
self.silent = not settings_required
self.relative = instance_relative_config
self._init_app()

def _init_app(self):
self._load_flask_settings()
self._load_custom_settings()

def _load_flask_settings(self):
"""
This loads the 'core' settings, ie, anything you could set directly
on a Flask app. These can be specified in the following order, each
overriding the last, if specified:
- via config mapping
- via Flask settings.cfg file
- via environment variable 'FLASK_SETTINGS'
"""
config_not_loaded = True
if self.config is not None:
# load the config if passed in
self.app.config.from_mapping(self.config)
config_not_loaded = False
# load the instance config, if it exists and/or is required
try:
self.app.config.from_pyfile('settings.cfg', silent=self.silent)
config_not_loaded = False
except Exception:
config_not_loaded = True
# Load any additional config specified in the FLASK_SETTINGS file,
# if it exists. We only want to fail in the case where settings are
# required by the app.
if ((config_not_loaded and not self.silent) or
os.environ.get('FLASK_SETTINGS')):
self.app.config.from_envvar('FLASK_SETTINGS')

def _load_custom_settings(self):
"""
Load any custom configuration for the app from:
- app.config['CUSTOM_SETTINGS']
- environment variable 'CUSTOM_SETTINGS'
This class provides compatibility with versions of scaffold that
expect an instance with an 'app' attribute. All of the parameters are
the same as BaseScaffold and are passed directly through unmodified.
"""
configurator = AppConfigurator(self.app, self.relative)
if self.app.config.get('CUSTOM_SETTINGS') is not None:
# load the config if passed in
custom = self.app.config.get('CUSTOM_SETTINGS')
configurator.parse(custom)
# Next, load from override file, if specified
if os.environ.get('CUSTOM_SETTINGS') is not None:
custom = os.environ.get('CUSTOM_SETTINGS')
configurator.parse(custom)
super().__init__(app, name, config, settings_required,
instance_path, instance_relative_config)
self.app = app or self.flask_app
102 changes: 102 additions & 0 deletions src/flask_container_scaffold/base_scaffold.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import os

from flask import Flask

from flask_container_scaffold.app_configurator import AppConfigurator


class BaseScaffold(object):

def __init__(self, app=None,
name=__name__, config=None,
settings_required=False,
instance_path=None,
instance_relative_config=True):
"""
This base class provides a way to dynamically configure a Flask
application.

:param obj app: An existing Flask application, if passed, otherwise we
will create a new one
:param str name: The name of the application, defaults to __name__.
:param dict config: A dict of configuration details. This can include
standard Flask configuration keys, like 'TESTING', or
'CUSTOM_SETTINGS' (which can be a string referencing a file with
custom configuration, or a dictionary containing any values your
application may need) to make them available to the application
during runtime
:param bool settings_required: Whether your app requires certain
settings be specified in a settings.cfg file
:param str instance_path: Passthrough parameter to flask. An
alternative instance path for the application. By default
the folder 'instance' next to the package or module is
assumed to be the instance path.
:param bool instance_relative_config: Passthrough parameter to flask.
If set to True relative filenames for loading the config
are assumed to be relative to the instance path instead of
the application root.

"""
# TODO: Consider taking **kwargs here, so we can automatically support
# all params the flask object takes, and just pass them through. Keep
# the ones we already have, as they are needed for the current code to
# work.
Flask.jinja_options = dict(Flask.jinja_options, trim_blocks=True,
lstrip_blocks=True)
self.flask_app = app or Flask(
name,
instance_relative_config=instance_relative_config,
instance_path=instance_path,
)
self.config = config
self.silent = not settings_required
self.relative = instance_relative_config
self._init_app()

def _init_app(self):
self._load_flask_settings()
self._load_custom_settings()

def _load_flask_settings(self):
"""
This loads the 'core' settings, ie, anything you could set directly
on a Flask app. These can be specified in the following order, each
overriding the last, if specified:
- via config mapping
- via Flask settings.cfg file
- via environment variable 'FLASK_SETTINGS'
"""
config_not_loaded = True
if self.config is not None:
# load the config if passed in
self.flask_app.config.from_mapping(self.config)
config_not_loaded = False
# load the instance config, if it exists and/or is required
try:
self.flask_app.config.from_pyfile('settings.cfg',
silent=self.silent)
config_not_loaded = False
except Exception:
config_not_loaded = True
# Load any additional config specified in the FLASK_SETTINGS file,
# if it exists. We only want to fail in the case where settings are
# required by the app.
if ((config_not_loaded and not self.silent) or
os.environ.get('FLASK_SETTINGS')):
self.flask_app.config.from_envvar('FLASK_SETTINGS')

def _load_custom_settings(self):
"""
Load any custom configuration for the app from:
- app.config['CUSTOM_SETTINGS']
- environment variable 'CUSTOM_SETTINGS'
"""
configurator = AppConfigurator(self.flask_app, self.relative)
if self.flask_app.config.get('CUSTOM_SETTINGS') is not None:
# load the config if passed in
custom = self.flask_app.config.get('CUSTOM_SETTINGS')
configurator.parse(custom)
# Next, load from override file, if specified
if os.environ.get('CUSTOM_SETTINGS') is not None:
custom = os.environ.get('CUSTOM_SETTINGS')
configurator.parse(custom)
30 changes: 30 additions & 0 deletions src/flask_container_scaffold/celery_scaffold.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
from celery import Celery

from flask_container_scaffold.base_scaffold import BaseScaffold


class CeleryScaffold(BaseScaffold):

def __init__(self, flask_app=None, name=__name__, config=None,
settings_required=False,
instance_path=None,
instance_relative_config=True):
"""
This class provides both a flask 'app' and a celery 'app' that has been
configured via flask. All of the parameters are the same as BaseScaffold.
Any naming changes are noted below.

:param obj flask_app: An existing Flask application, if passed,
otherwise we will create a new one using BaseScaffold. This is the same
as the app parameter in BaseScaffold.
"""
super().__init__(flask_app, name, config, settings_required,
instance_path, instance_relative_config)
self.flask_app = flask_app or self.flask_app
self.celery_app = Celery(self.flask_app.name)
self.celery_app.config_from_object(self.flask_app.config.get("CELERY"))
self.celery_app.set_default()
jguiditta marked this conversation as resolved.
Show resolved Hide resolved
# Add the celery app as an extension to the flask app so it can be easily
# accessed if a flask application factory pattern is used.
# see https://flask.palletsprojects.com/en/2.3.x/patterns/celery/ for details.
self.flask_app.extensions["celery"] = self.celery_app
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a good improvement if it helps when the factory pattern is used, as I know a number of our applications do this currently.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This provides a lot of flexibility, if you want to access the celery app from the flask app you can, or you can use the class attribute. This doesn't force a single implementation pattern, which I like.

2 changes: 1 addition & 1 deletion test-requirements.txt
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
-i https://pypi.python.org/simple
-e .[devbase,test]
-e .[devbase,test,celery]
65 changes: 65 additions & 0 deletions tests/unit/test_celery.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import pytest

from celery import Celery
from flask import Flask

from flask_container_scaffold.celery_scaffold import CeleryScaffold


def test_celery_flask_empty_config():
"""
GIVEN an instance of CeleryScaffold with an empty config
WHEN we try to create the app
THEN we get a celery app and a flask app
"""
scaffold = CeleryScaffold()
assert scaffold.flask_app is not None
assert isinstance(scaffold.flask_app, Flask)
assert scaffold.celery_app is not None
assert isinstance(scaffold.celery_app, Celery)


def test_flask_extension():
"""
Given an instance of CeleryScaffold
WHEN the apps are created
THEN the flask extension has a celery element
AND the celery app matches the flask extension
"""
scaffold = CeleryScaffold()
assert scaffold.flask_app is not None
assert scaffold.celery_app is not None
assert scaffold.flask_app.extensions.get("celery") is not None
assert scaffold.celery_app == scaffold.flask_app.extensions["celery"]


def test_celery_broker_set():
"""
GIVEN an instance of CeleryScaffold
AND a config with a broker url
WHEN we create the app
THEN we get a celery app with a broker url matching the config
"""
config = {'CELERY': {'broker': 'pyamqp://'}}
scaffold = CeleryScaffold(config=config)
app = scaffold.celery_app
assert app is not None
assert isinstance(app, Celery)
assert config['CELERY']['broker'] == app.conf.find_value_for_key('broker')


def test_celery_bad_config():
"""
GIVEN an instance of CeleryScaffold
AND a config with a bad config item
WHEN we create the app
THEN we get a celery app
AND the config doesn't have the bad item
"""
config = {'CELERY': {'bad_config_item': 'my_bad_config'}}
scaffold = CeleryScaffold(config=config)
app = scaffold.celery_app
assert app is not None
assert isinstance(app, Celery)
with pytest.raises(KeyError):
app.conf.find_value_for_key('bad_config_item')
Loading