Test Failed
Push — main ( 3648dc...bd02c0 )
by Jochen
04:40
created

byceps.application   A

Complexity

Total Complexity 24

Size/Duplication

Total Lines 215
Duplicated Lines 0 %

Test Coverage

Coverage 88.51%

Importance

Changes 0
Metric Value
eloc 114
dl 0
loc 215
ccs 77
cts 87
cp 0.8851
rs 10
c 0
b 0
f 0
wmc 24

10 Functions

Rating   Name   Duplication   Size   Complexity  
A create_app() 0 44 3
A _find_site_template_context_processor_cached() 0 17 2
A _init_admin_app() 0 10 2
A _get_config_from_environment() 0 12 3
A _find_site_template_context_processor() 0 26 4
A _init_site_app() 0 8 1
A _get_site_template_context() 0 10 2
A _load_announce_signal_handlers() 0 5 1
A _configure() 0 26 5
A _add_static_file_url_rules() 0 7 1
1
"""
2
byceps.application
3
~~~~~~~~~~~~~~~~~~
4
5
:Copyright: 2014-2022 Jochen Kupperschmidt
6
:License: Revised BSD (see `LICENSE` file for details)
7
"""
8
9 1
from __future__ import annotations
10 1
from collections.abc import Iterator
11 1
from importlib import import_module
12 1
import os
13 1
from pathlib import Path
14 1
from typing import Any, Callable, Optional
15
16 1
from flask import abort, current_app, Flask, g
17 1
from flask_babel import Babel
18 1
import jinja2
19 1
from redis import Redis
20
import rtoml
21 1
22 1
from .blueprints.blueprints import register_blueprints
23 1
from . import config, config_defaults
24 1
from .database import db
25 1
from .util.authorization import has_current_user_permission, load_permissions
26 1
from .util.l10n import get_current_user_locale
27 1
from .util import templatefilters, templatefunctions
28
from .util.templating import SiteTemplateOverridesLoader
29
30 1
31
def create_app(
32
    *,
33
    config_filename: Optional[Path | str] = None,
34
    config_overrides: Optional[dict[str, Any]] = None,
35
) -> Flask:
36 1
    """Create the actual Flask application."""
37
    app = Flask('byceps')
38 1
39 1
    _configure(app, config_filename, config_overrides)
40 1
41
    # Throw an exception when an undefined name is referenced in a template.
42
    # NB: Set via `app.jinja_options['undefined'] = ` instead of
43 1
    #     `app.jinja_env.undefined = ` as that would create the Jinja
44 1
    #      environment too early.
45
    app.jinja_options['undefined'] = jinja2.StrictUndefined
46
47 1
    babel = Babel(app)
48
    babel.locale_selector_func = get_current_user_locale
49 1
50
    # Initialize database.
51
    db.init_app(app)
52
53
    # Initialize Redis client.
54
    app.redis_client = Redis.from_url(app.config['REDIS_URL'])
55 1
56
    app_mode = config.get_app_mode(app)
57 1
58 1
    load_permissions()
59
60
    register_blueprints(app, app_mode)
61 1
62
    templatefilters.register(app)
63
    templatefunctions.register(app)
64 1
65
    _add_static_file_url_rules(app)
66 1
67
    if app_mode.is_admin():
68 1
        _init_admin_app(app)
69
    elif app_mode.is_site():
70 1
        _init_site_app(app)
71
72 1
    _load_announce_signal_handlers()
73 1
74
    return app
75 1
76
77 1
def _configure(
78 1
    app: Flask,
79 1
    config_filename: Optional[Path | str] = None,
80 1
    config_overrides: Optional[dict[str, Any]] = None,
81
) -> None:
82 1
    """Configure application from file, environment variables, and defaults."""
83
    app.config.from_object(config_defaults)
84 1
85
    if config_filename is not None:
86
        if isinstance(config_filename, str):
87 1
            config_filename = Path(config_filename)
88
89 1
        if config_filename.suffix == '.py':
90
            app.config.from_pyfile(str(config_filename))
91
        else:
92
            app.config.from_file(str(config_filename), load=rtoml.load)
93
    else:
94
        app.config.from_envvar('BYCEPS_CONFIG')
95
96 1
    if config_overrides is not None:
97 1
        app.config.from_mapping(config_overrides)
98 1
99
    # Allow configuration values to be overridden by environment variables.
100
    app.config.update(_get_config_from_environment())
101 1
102
    config.init_app(app)
103 1
104
105
def _get_config_from_environment() -> Iterator[tuple[str, str]]:
106
    """Obtain selected config values from environment variables."""
107
    for key in (
108
        'APP_MODE',
109
        'REDIS_URL',
110
        'SECRET_KEY',
111 1
        'SITE_ID',
112
        'SQLALCHEMY_DATABASE_URI',
113 1
    ):
114
        value = os.environ.get(key)
115 1
        if value:
116 1
            yield key, value
117
118
119
def _add_static_file_url_rules(app: Flask) -> None:
120 1
    """Add URL rules to for static files."""
121
    app.add_url_rule(
122
        '/sites/<site_id>/<path:filename>',
123 1
        endpoint='site_file',
124
        methods=['GET'],
125
        build_only=True,
126 1
    )
127
128
129 1
def _init_admin_app(app: Flask) -> None:
130 1
    """Initialize admin application."""
131
    import rq_dashboard
132
133 1
    @rq_dashboard.blueprint.before_request
134
    def require_permission():
135 1
        if not has_current_user_permission('jobs.view'):
136
            abort(403)
137
138
    app.register_blueprint(rq_dashboard.blueprint, url_prefix='/admin/rq')
139 1
140 1
141
def _init_site_app(app: Flask) -> None:
142
    """Initialize site application."""
143
    # Incorporate site-specific template overrides.
144
    app.jinja_loader = SiteTemplateOverridesLoader()
145 1
146
    # Set up site-aware template context processor.
147
    app._site_context_processors = {}
148
    app.context_processor(_get_site_template_context)
149
150
151
def _get_site_template_context() -> dict[str, Any]:
152
    """Return the site-specific additions to the template context."""
153
    site_context_processor = _find_site_template_context_processor_cached(
154
        g.site_id
155
    )
156 1
157 1
    if not site_context_processor:
158
        return {}
159 1
160 1
    return site_context_processor()
161 1
162
163
def _find_site_template_context_processor_cached(
164 1
    site_id: str,
165
) -> Optional[Callable[[], dict[str, Any]]]:
166
    """Return the template context processor for the site.
167
168
    A processor will be cached after it has been obtained for the first
169
    time.
170
    """
171
    # `None` is a valid value for a site that does not specify a
172
    # template context processor.
173 1
174 1
    if site_id in current_app._site_context_processors:
175 1
        return current_app._site_context_processors.get(site_id)
176 1
    else:
177
        context_processor = _find_site_template_context_processor(site_id)
178 1
        current_app._site_context_processors[site_id] = context_processor
179
        return context_processor
180
181
182
def _find_site_template_context_processor(
183
    site_id: str,
184
) -> Optional[Callable[[], dict[str, Any]]]:
185
    """Import a template context processor from the site's package.
186
187
    If a site package contains a module named `extension` and that
188
    contains a top-level callable named `template_context_processor`,
189
    then that callable is imported and returned.
190
    """
191
    module_name = f'sites.{site_id}.extension'
192 1
    try:
193
        module = import_module(module_name)
194
    except ModuleNotFoundError:
195
        # No extension module found in site package.
196 1
        return None
197
198
    context_processor = getattr(module, 'template_context_processor', None)
199
    if context_processor is None:
200
        # Context processor not found in module.
201
        return None
202
203
    if not callable(context_processor):
204
        # Context processor object is not callable.
205
        return None
206
207
    return context_processor
208
209
210
def _load_announce_signal_handlers() -> None:
211
    """Import modules containing handlers so they connect to the
212
    corresponding signals.
213
    """
214
    from .announce import connections  # noqa: F401
215