Skip to content

Commit d7e752a

Browse files
committed
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 09e0236 commit d7e752a

File tree

8 files changed

+227
-72
lines changed

8 files changed

+227
-72
lines changed

.github/workflows/test.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ jobs:
2121
pip install tox tox-gh-actions
2222
- name: Run Tox
2323
# Run tox using the version of Python in `PATH`
24-
run: tox -epy
24+
run: tox
2525
flake8:
2626
runs-on: ubuntu-latest
2727
steps:

README.md

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

132+
### Using 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
137+
138+
celery_scaffold = CeleryScaffold(name=__name__, config=config)
139+
flask_app = celery_scaffold.flask_app
140+
celery_app = celery_scaffold.celery_app
141+
132142
### Using the parse_input method
133143

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

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ package_dir =
2323
= src
2424
packages = find:
2525
install_requires =
26+
celery
2627
flask
2728
pydantic
2829
toolchest
Lines changed: 11 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,28 +1,26 @@
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.
12+
This class provides compatibility with older versions of scaffold that
13+
expect an instance with an 'app' attribute.
1714
1815
:param obj app: An existing Flask application, if passed, otherwise we
1916
will create a new one
2017
:param str name: The name of the application, defaults to __name__.
2118
:param dict config: A dict of configuration details. This can include
2219
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
20+
'CUSTOM_SETTINGS' (which can be a string referencing a file with
21+
custom configuration, or a dictionary containing any values your
22+
application may need) to make them available to the application
23+
during runtime
2624
:param bool settings_required: Whether your app requires certain
2725
settings be specified in a settings.cfg file
2826
:param str instance_path: Passthrough parameter to flask. An
@@ -35,64 +33,6 @@ def __init__(self, app=None,
3533
the application root.
3634
3735
"""
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'
89-
"""
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)
36+
super().__init__(app, name, config, settings_required,
37+
instance_path, instance_relative_config)
38+
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: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
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.
15+
16+
:param obj flask_app: An existing Flask application, if passed,
17+
otherwise we will create a new one using BaseScaffold.
18+
:param str name: The name of the application, defaults to __name__.
19+
:param dict config: A dict of configuration details. This can include
20+
standard Flask configuration keys, like 'TESTING', or
21+
'CUSTOM_SETTINGS' (which can be a string referencing a file with
22+
custom configuration, or a dictionary containing any values your
23+
application may need) to make them available to the application
24+
during runtime
25+
:param bool settings_required: Whether your app requires certain
26+
settings be specified in a settings.cfg file
27+
:param str instance_path: Passthrough parameter to flask. An
28+
alternative instance path for the application. By default
29+
the folder 'instance' next to the package or module is
30+
assumed to be the instance path.
31+
:param bool instance_relative_config: Passthrough parameter to flask.
32+
If set to True relative filenames for loading the config
33+
are assumed to be relative to the instance path instead of
34+
the application root.
35+
36+
"""
37+
super().__init__(flask_app, name, config, settings_required,
38+
instance_path, instance_relative_config)
39+
self.flask_app = flask_app or self.flask_app
40+
self.celery_app = Celery(self.flask_app.name)
41+
self.celery_app.config_from_object(self.flask_app.config.get("CELERY"))
42+
self.celery_app.set_default()

tests/unit/test_celery.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
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_celery_broker_set():
23+
"""
24+
GIVEN an instance of CeleryScaffold
25+
AND a config with a broker url
26+
WHEN we create the app
27+
THEN we get a celery app with a broker url matching the config
28+
"""
29+
config = {'CELERY': {'broker': 'pyamqp://'}}
30+
scaffold = CeleryScaffold(config=config)
31+
app = scaffold.celery_app
32+
assert app is not None
33+
assert isinstance(app, Celery)
34+
assert config['CELERY']['broker'] == app.conf.find_value_for_key('broker')
35+
36+
37+
def test_celery_bad_config():
38+
"""
39+
GIVEN an instance of CeleryScaffold
40+
AND a config with a bad config item
41+
WHEN we create the app
42+
THEN we get a celery app
43+
AND the config doesn't have the bad item
44+
"""
45+
config = {'CELERY': {'bad_config_item': 'my_bad_config'}}
46+
scaffold = CeleryScaffold(config=config)
47+
app = scaffold.celery_app
48+
assert app is not None
49+
assert isinstance(app, Celery)
50+
with pytest.raises(KeyError):
51+
app.conf.find_value_for_key('bad_config_item')

tox.ini

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,21 @@ envlist =
33
py{37,38,39,310,311}
44
flake8
55

6+
[gh-actions]
7+
python =
8+
3.7: py37
9+
3.8: py38
10+
3.9: py39
11+
3.10: py310
12+
3.11: py311, flake8
13+
614
[testenv]
715
passenv=HOME
816
sitepackages = False
917
deps = -r{toxinidir}/requirements.txt
1018
-r{toxinidir}/test-requirements.txt
1119
-r{toxinidir}/dist-requirements.txt
20+
py37: importlib-metadata<5.0
1221
commands =
1322
pytest --cov-report=term-missing --cov=src tests
1423

0 commit comments

Comments
 (0)