Skip to content

Commit 71ccbe0

Browse files
fuzzball81jguiditta
authored andcommitted
Adding celery scaffold to the project.
Adding celery_scaffold for use with flask based projects. It provides a way to configure celery for use, using the flask configuration files. It also provides a celery_app and a flask_app that can be used in your project. A base_scaffold was pulled out due to the celery worker assuming any 'app' attribute is of type Celery. The original app_scaffold is available to provide backward compatibility. It leverages the new base_scaffold and sets the 'app' attribute to flask_app to ensure existing use cases are handled. Signed-off-by: Jason Joyce <[email protected]>
1 parent 8c6eded commit 71ccbe0

File tree

8 files changed

+256
-89
lines changed

8 files changed

+256
-89
lines changed

Pipfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,5 @@ flask-container-scaffold = {path = ".",extras = ["devbase","test","docs","dist"]
99
[packages]
1010
flask-container-scaffold = {path = ".",editable = true}
1111

12+
[celery]
13+
flask-container-scaffold = {file = ".", editable = true, extras = ["celery"]}

README.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,51 @@ example:
129129
},
130130
})
131131

132+
### CeleryScaffold
133+
134+
This class has all of the same support as the above AppScaffold and takes
135+
the same parameters. Each CeleryScaffold instance has a flask_app and celery_app
136+
attribute that can be used in your project. More information about celery can
137+
be found [here](https://docs.celeryq.dev/en/stable/getting-started/introduction.html).
138+
Information on integrating celery with flask can be found in flask's
139+
[documentation](https://flask.palletsprojects.com/en/2.3.x/patterns/celery/).
140+
141+
142+
#### Installation
143+
144+
pip install flask-container-scaffold['celery']
145+
146+
or
147+
148+
pipenv install --categories celery
149+
150+
#### Basic Usage
151+
152+
celery_scaffold = CeleryScaffold(name=__name__, config=config)
153+
flask_app = celery_scaffold.flask_app
154+
celery_app = celery_scaffold.celery_app
155+
156+
#### Basic Configuration
157+
158+
All configuration is done via a 'CELERY' key in a configuration dictionary. The
159+
'CELERY' element itself is a dictionary of configuration items. More details on the
160+
available configuration items for celery can be found [here](https://docs.celeryq.dev/en/stable/userguide/configuration.html).
161+
Below is a basic example in yaml format that uses a local rabbitmq broker, json serialization, and no result backend.
162+
163+
```
164+
---
165+
166+
CELERY:
167+
broker: "pyamqp://[email protected]//"
168+
result_persistent: False
169+
task_serializer: "json"
170+
accept_content:
171+
- "json" # Ignore other content
172+
result_serializer: "json"
173+
result_expires: "300"
174+
broker_connection_retry_on_startup: 'False'
175+
```
176+
132177
### Using the parse_input method
133178

134179
This method is used to validate incoming data against a pydantic model. A

setup.cfg

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ install_requires =
2828
toolchest
2929

3030
[options.extras_require]
31+
celery =
32+
celery
33+
3134
devbase =
3235
tox
3336

Lines changed: 8 additions & 88 deletions
Original file line numberDiff line numberDiff line change
@@ -1,98 +1,18 @@
1-
import os
1+
from flask_container_scaffold.base_scaffold import BaseScaffold
22

3-
from flask import Flask
43

5-
from flask_container_scaffold.app_configurator import AppConfigurator
6-
7-
8-
class AppScaffold(object):
4+
class AppScaffold(BaseScaffold):
95

106
def __init__(self, app=None,
117
name=__name__, config=None,
128
settings_required=False,
139
instance_path=None,
1410
instance_relative_config=True):
1511
"""
16-
This class provides a way to dynamically configure a Flask application.
17-
18-
:param obj app: An existing Flask application, if passed, otherwise we
19-
will create a new one
20-
:param str name: The name of the application, defaults to __name__.
21-
:param dict config: A dict of configuration details. This can include
22-
standard Flask configuration keys, like 'TESTING', or
23-
'CUSTOM_SETTINGS' (which can be a string referencing a file with custom
24-
configuration, or a dictionary containing any values your application
25-
may need) to make them available to the application during runtime
26-
:param bool settings_required: Whether your app requires certain
27-
settings be specified in a settings.cfg file
28-
:param str instance_path: Passthrough parameter to flask. An
29-
alternative instance path for the application. By default
30-
the folder 'instance' next to the package or module is
31-
assumed to be the instance path.
32-
:param bool instance_relative_config: Passthrough parameter to flask.
33-
If set to True relative filenames for loading the config
34-
are assumed to be relative to the instance path instead of
35-
the application root.
36-
37-
"""
38-
# TODO: Consider taking **kwargs here, so we can automatically support
39-
# all params the flask object takes, and just pass them through. Keep
40-
# the ones we already have, as they are needed for the current code to
41-
# work.
42-
Flask.jinja_options = dict(Flask.jinja_options, trim_blocks=True,
43-
lstrip_blocks=True)
44-
self.app = (app or
45-
Flask(name,
46-
instance_relative_config=instance_relative_config,
47-
instance_path=instance_path))
48-
self.config = config
49-
self.silent = not settings_required
50-
self.relative = instance_relative_config
51-
self._init_app()
52-
53-
def _init_app(self):
54-
self._load_flask_settings()
55-
self._load_custom_settings()
56-
57-
def _load_flask_settings(self):
58-
"""
59-
This loads the 'core' settings, ie, anything you could set directly
60-
on a Flask app. These can be specified in the following order, each
61-
overriding the last, if specified:
62-
- via config mapping
63-
- via Flask settings.cfg file
64-
- via environment variable 'FLASK_SETTINGS'
65-
"""
66-
config_not_loaded = True
67-
if self.config is not None:
68-
# load the config if passed in
69-
self.app.config.from_mapping(self.config)
70-
config_not_loaded = False
71-
# load the instance config, if it exists and/or is required
72-
try:
73-
self.app.config.from_pyfile('settings.cfg', silent=self.silent)
74-
config_not_loaded = False
75-
except Exception:
76-
config_not_loaded = True
77-
# Load any additional config specified in the FLASK_SETTINGS file,
78-
# if it exists. We only want to fail in the case where settings are
79-
# required by the app.
80-
if ((config_not_loaded and not self.silent) or
81-
os.environ.get('FLASK_SETTINGS')):
82-
self.app.config.from_envvar('FLASK_SETTINGS')
83-
84-
def _load_custom_settings(self):
85-
"""
86-
Load any custom configuration for the app from:
87-
- app.config['CUSTOM_SETTINGS']
88-
- environment variable 'CUSTOM_SETTINGS'
12+
This class provides compatibility with versions of scaffold that
13+
expect an instance with an 'app' attribute. All of the parameters are
14+
the same as BaseScaffold and are passed directly through unmodified.
8915
"""
90-
configurator = AppConfigurator(self.app, self.relative)
91-
if self.app.config.get('CUSTOM_SETTINGS') is not None:
92-
# load the config if passed in
93-
custom = self.app.config.get('CUSTOM_SETTINGS')
94-
configurator.parse(custom)
95-
# Next, load from override file, if specified
96-
if os.environ.get('CUSTOM_SETTINGS') is not None:
97-
custom = os.environ.get('CUSTOM_SETTINGS')
98-
configurator.parse(custom)
16+
super().__init__(app, name, config, settings_required,
17+
instance_path, instance_relative_config)
18+
self.app = app or self.flask_app
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import os
2+
3+
from flask import Flask
4+
5+
from flask_container_scaffold.app_configurator import AppConfigurator
6+
7+
8+
class BaseScaffold(object):
9+
10+
def __init__(self, app=None,
11+
name=__name__, config=None,
12+
settings_required=False,
13+
instance_path=None,
14+
instance_relative_config=True):
15+
"""
16+
This base class provides a way to dynamically configure a Flask
17+
application.
18+
19+
:param obj app: An existing Flask application, if passed, otherwise we
20+
will create a new one
21+
:param str name: The name of the application, defaults to __name__.
22+
:param dict config: A dict of configuration details. This can include
23+
standard Flask configuration keys, like 'TESTING', or
24+
'CUSTOM_SETTINGS' (which can be a string referencing a file with
25+
custom configuration, or a dictionary containing any values your
26+
application may need) to make them available to the application
27+
during runtime
28+
:param bool settings_required: Whether your app requires certain
29+
settings be specified in a settings.cfg file
30+
:param str instance_path: Passthrough parameter to flask. An
31+
alternative instance path for the application. By default
32+
the folder 'instance' next to the package or module is
33+
assumed to be the instance path.
34+
:param bool instance_relative_config: Passthrough parameter to flask.
35+
If set to True relative filenames for loading the config
36+
are assumed to be relative to the instance path instead of
37+
the application root.
38+
39+
"""
40+
# TODO: Consider taking **kwargs here, so we can automatically support
41+
# all params the flask object takes, and just pass them through. Keep
42+
# the ones we already have, as they are needed for the current code to
43+
# work.
44+
Flask.jinja_options = dict(Flask.jinja_options, trim_blocks=True,
45+
lstrip_blocks=True)
46+
self.flask_app = app or Flask(
47+
name,
48+
instance_relative_config=instance_relative_config,
49+
instance_path=instance_path,
50+
)
51+
self.config = config
52+
self.silent = not settings_required
53+
self.relative = instance_relative_config
54+
self._init_app()
55+
56+
def _init_app(self):
57+
self._load_flask_settings()
58+
self._load_custom_settings()
59+
60+
def _load_flask_settings(self):
61+
"""
62+
This loads the 'core' settings, ie, anything you could set directly
63+
on a Flask app. These can be specified in the following order, each
64+
overriding the last, if specified:
65+
- via config mapping
66+
- via Flask settings.cfg file
67+
- via environment variable 'FLASK_SETTINGS'
68+
"""
69+
config_not_loaded = True
70+
if self.config is not None:
71+
# load the config if passed in
72+
self.flask_app.config.from_mapping(self.config)
73+
config_not_loaded = False
74+
# load the instance config, if it exists and/or is required
75+
try:
76+
self.flask_app.config.from_pyfile('settings.cfg',
77+
silent=self.silent)
78+
config_not_loaded = False
79+
except Exception:
80+
config_not_loaded = True
81+
# Load any additional config specified in the FLASK_SETTINGS file,
82+
# if it exists. We only want to fail in the case where settings are
83+
# required by the app.
84+
if ((config_not_loaded and not self.silent) or
85+
os.environ.get('FLASK_SETTINGS')):
86+
self.flask_app.config.from_envvar('FLASK_SETTINGS')
87+
88+
def _load_custom_settings(self):
89+
"""
90+
Load any custom configuration for the app from:
91+
- app.config['CUSTOM_SETTINGS']
92+
- environment variable 'CUSTOM_SETTINGS'
93+
"""
94+
configurator = AppConfigurator(self.flask_app, self.relative)
95+
if self.flask_app.config.get('CUSTOM_SETTINGS') is not None:
96+
# load the config if passed in
97+
custom = self.flask_app.config.get('CUSTOM_SETTINGS')
98+
configurator.parse(custom)
99+
# Next, load from override file, if specified
100+
if os.environ.get('CUSTOM_SETTINGS') is not None:
101+
custom = os.environ.get('CUSTOM_SETTINGS')
102+
configurator.parse(custom)
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
from celery import Celery
2+
3+
from flask_container_scaffold.base_scaffold import BaseScaffold
4+
5+
6+
class CeleryScaffold(BaseScaffold):
7+
8+
def __init__(self, flask_app=None, name=__name__, config=None,
9+
settings_required=False,
10+
instance_path=None,
11+
instance_relative_config=True):
12+
"""
13+
This class provides both a flask 'app' and a celery 'app' that has been
14+
configured via flask. All of the parameters are the same as BaseScaffold.
15+
Any naming changes are noted below.
16+
17+
:param obj flask_app: An existing Flask application, if passed,
18+
otherwise we will create a new one using BaseScaffold. This is the same
19+
as the app parameter in BaseScaffold.
20+
"""
21+
super().__init__(flask_app, name, config, settings_required,
22+
instance_path, instance_relative_config)
23+
self.flask_app = flask_app or self.flask_app
24+
self.celery_app = Celery(self.flask_app.name)
25+
self.celery_app.config_from_object(self.flask_app.config.get("CELERY"))
26+
self.celery_app.set_default()
27+
# Add the celery app as an extension to the flask app so it can be easily
28+
# accessed if a flask application factory pattern is used.
29+
# see https://flask.palletsprojects.com/en/2.3.x/patterns/celery/ for details.
30+
self.flask_app.extensions["celery"] = self.celery_app

test-requirements.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
-i https://pypi.python.org/simple
2-
-e .[devbase,test]
2+
-e .[devbase,test,celery]

tests/unit/test_celery.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import pytest
2+
3+
from celery import Celery
4+
from flask import Flask
5+
6+
from flask_container_scaffold.celery_scaffold import CeleryScaffold
7+
8+
9+
def test_celery_flask_empty_config():
10+
"""
11+
GIVEN an instance of CeleryScaffold with an empty config
12+
WHEN we try to create the app
13+
THEN we get a celery app and a flask app
14+
"""
15+
scaffold = CeleryScaffold()
16+
assert scaffold.flask_app is not None
17+
assert isinstance(scaffold.flask_app, Flask)
18+
assert scaffold.celery_app is not None
19+
assert isinstance(scaffold.celery_app, Celery)
20+
21+
22+
def test_flask_extension():
23+
"""
24+
Given an instance of CeleryScaffold
25+
WHEN the apps are created
26+
THEN the flask extension has a celery element
27+
AND the celery app matches the flask extension
28+
"""
29+
scaffold = CeleryScaffold()
30+
assert scaffold.flask_app is not None
31+
assert scaffold.celery_app is not None
32+
assert scaffold.flask_app.extensions.get("celery") is not None
33+
assert scaffold.celery_app == scaffold.flask_app.extensions["celery"]
34+
35+
36+
def test_celery_broker_set():
37+
"""
38+
GIVEN an instance of CeleryScaffold
39+
AND a config with a broker url
40+
WHEN we create the app
41+
THEN we get a celery app with a broker url matching the config
42+
"""
43+
config = {'CELERY': {'broker': 'pyamqp://'}}
44+
scaffold = CeleryScaffold(config=config)
45+
app = scaffold.celery_app
46+
assert app is not None
47+
assert isinstance(app, Celery)
48+
assert config['CELERY']['broker'] == app.conf.find_value_for_key('broker')
49+
50+
51+
def test_celery_bad_config():
52+
"""
53+
GIVEN an instance of CeleryScaffold
54+
AND a config with a bad config item
55+
WHEN we create the app
56+
THEN we get a celery app
57+
AND the config doesn't have the bad item
58+
"""
59+
config = {'CELERY': {'bad_config_item': 'my_bad_config'}}
60+
scaffold = CeleryScaffold(config=config)
61+
app = scaffold.celery_app
62+
assert app is not None
63+
assert isinstance(app, Celery)
64+
with pytest.raises(KeyError):
65+
app.conf.find_value_for_key('bad_config_item')

0 commit comments

Comments
 (0)