Skip to content

Commit

Permalink
Enable plugin list to be an app creation param, useful for plugin tes…
Browse files Browse the repository at this point in the history
…ts (#220)

* let code pass in a lit of plugins, which is very useful for plugins running tests

* refactor a bit in config utilities, add some logging

* document new parameter

* for reading in requirements, ignore another possible comment which pip-tools might add

* document caveats when testing plugins

* add changelog entry

* do not rely on a actual secret_key file in testing / CI
  • Loading branch information
nhoening committed Oct 25, 2021
1 parent 232427d commit 3a3506f
Show file tree
Hide file tree
Showing 7 changed files with 94 additions and 35 deletions.
13 changes: 4 additions & 9 deletions ci/SETUP.sh
@@ -1,13 +1,8 @@
#!/bin/bash

##############################################
# This script sets up a new FlexMeasures instance
##############################################


# A secret key is used by Flask, for example, to encrypt the session
mkdir -p ./instance
head -c 24 /dev/urandom > ./instance/secret_key
######################################################################
# This script sets up a new FlexMeasures instance in a CI environment
######################################################################


# Install dependencies
Expand Down Expand Up @@ -35,4 +30,4 @@ while [[ true ]]; do
fi
done

psql -h $PGHOST -p $PGPORT -c "create extension if not exists cube; create extension if not exists earthdistance;" -U $PGUSER $PGDB;
psql -h $PGHOST -p $PGPORT -c "create extension if not exists cube; create extension if not exists earthdistance;" -U $PGUSER $PGDB;
1 change: 1 addition & 0 deletions documentation/changelog.rst
Expand Up @@ -12,6 +12,7 @@ New features
* Set a logo for the top left corner with the new FLEXMEASURES_MENU_LOGO_PATH setting [see `PR #184 <http://www.github.com/SeitaBV/flexmeasures/pull/184>`_]
* Add an extra style-sheet which applies to all pages with the new FLEXMEASURES_EXTRA_CSS_PATH setting [see `PR #185 <http://www.github.com/SeitaBV/flexmeasures/pull/185>`_]
* Data sources can be further distinguished by what model (and version) they ran [see `PR #215 <http://www.github.com/SeitaBV/flexmeasures/pull/215>`_]
* Enable plugins to automate tests with app context [see `PR #220 <http://www.github.com/SeitaBV/flexmeasures/pull/220>`_]

Bugfixes
-----------
Expand Down
39 changes: 37 additions & 2 deletions documentation/dev/plugins.rst
Expand Up @@ -171,8 +171,6 @@ But it can be achieved if you put the plugin path on the import path. Do it like
from my_other_file import my_function
Using a custom favicon icon
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Expand Down Expand Up @@ -207,6 +205,43 @@ Then, overwrite the ``/favicon.ico`` route which FlexMeasures uses to get the fa
Here we assume your favicon is a PNG file. You can also use a classic `.ico` file, then your mime type probably works best as ``image/x-icon``.


Notes on writing tests for your plugin
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Good software practice is to write automatable tests. We encourage you to also do this in your plugin.
We do, and our CookieCutter template for plugins (see above) has simple examples how that can work for the different use cases
(i.e. UI, API, CLI).

However, there are two caveats to look into:

* Your tests need a FlexMeasures app context. FlexMeasure's app creation function provides a way to inject a list of plugins directly. The following could be used for instance in your ``app`` fixture within the top-level ``conftest.py`` if you are using pytest:

.. code-block:: python
from flexmeasures.app import create as create_flexmeasures_app
from .. import __name__
test_app = create_flexmeasures_app(env="testing", plugins=[f"../"{__name__}])
* Test frameworks collect tests from your code and therefore might import your modules. This can interfere with the registration of routes on your Blueprint objects during plugin registration. Therefore, we recommend reloading your route modules, after the Blueprint is defined and before you import them. For example:

.. code-block:: python
my_plugin_ui_bp: Blueprint = Blueprint(
"MyPlugin-UI",
__name__,
template_folder="my_plugin/ui/templates",
static_folder="my_plugin/ui/static",
url_prefix="/MyPlugin",
)
# Now, before we import this dashboard module, in which the "/dashboard" route is attached to my_plugin_ui_bp,
# we make sure it's being imported now, *after* the Blueprint's creation.
importlib.reload(sys.modules["my_plugin.my_plugin.ui.views.dashboard"])
from my_plugin.ui.views import dashboard
The packaging path depends on your plugin's package setup, of course.


Validating arguments in your CLI commands with marshmallow
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

Expand Down
23 changes: 14 additions & 9 deletions flexmeasures/app.py
@@ -1,7 +1,7 @@
# flake8: noqa: E402
import os
import time
from typing import Optional
from typing import Optional, List

from flask import Flask, g, request
from flask.cli import load_dotenv
Expand All @@ -14,14 +14,20 @@
from rq import Queue


def create(env: Optional[str] = None, path_to_config: Optional[str] = None) -> Flask:
def create(
env: Optional[str] = None,
path_to_config: Optional[str] = None,
plugins: Optional[List[str]] = None,
) -> Flask:
"""
Create a Flask app and configure it.
Set the environment by setting FLASK_ENV as environment variable (also possible in .env).
Or, overwrite any FLASK_ENV setting by passing an env in directly (useful for testing for instance).
A path to a config file can be passed in (otherwise a config file will be searched in the home or instance directories)
A path to a config file can be passed in (otherwise a config file will be searched in the home or instance directories).
Also, a list of plugins can be set. Usually this works as a config setting, but this is useful for automated testing.
"""

from flexmeasures.utils import config_defaults
Expand All @@ -47,6 +53,8 @@ def create(env: Optional[str] = None, path_to_config: Optional[str] = None) -> F
# App configuration

read_config(app, custom_path_to_config=path_to_config)
if plugins:
app.config["FLEXMEASURES_PLUGINS"] += plugins
add_basic_error_handlers(app)
if not app.env in ("development", "documentation") and not app.testing:
init_sentry(app)
Expand Down Expand Up @@ -82,12 +90,9 @@ def create(env: Optional[str] = None, path_to_config: Optional[str] = None) -> F

# Some basic security measures

if not app.env == "documentation":
set_secret_key(app)
if app.config.get("SECURITY_PASSWORD_SALT", None) is None:
app.config["SECURITY_PASSWORD_SALT"] = app.config["SECRET_KEY"]
else:
app.config["SECRET_KEY"] = "dummy-secret-for-documentation-creation"
set_secret_key(app)
if app.config.get("SECURITY_PASSWORD_SALT", None) is None:
app.config["SECURITY_PASSWORD_SALT"] = app.config["SECRET_KEY"]
if not app.env in ("documentation", "development"):
SSLify(app)

Expand Down
3 changes: 2 additions & 1 deletion flexmeasures/utils/config_defaults.py
Expand Up @@ -176,6 +176,7 @@ class TestingConfig(Config):
LOGGING_LEVEL = logging.INFO
WTF_CSRF_ENABLED = False # also necessary for logging in during tests

SECRET_KEY = "dummy-key-for-testing"
SECURITY_PASSWORD_SALT = "$2b$19$abcdefghijklmnopqrstuv"
SQLALCHEMY_DATABASE_URI = (
"postgresql://flexmeasures_test:flexmeasures_test@localhost/flexmeasures_test"
Expand All @@ -194,4 +195,4 @@ class TestingConfig(Config):


class DocumentationConfig(Config):
pass
SECRET_KEY = "dummy-key-for-documentation"
49 changes: 35 additions & 14 deletions flexmeasures/utils/config_utils.py
Expand Up @@ -48,35 +48,42 @@ def configure_logging():
loggingDictConfig(flexmeasures_logging_config)


def read_config(app: Flask, custom_path_to_config: Optional[str]):
"""Read configuration from various expected sources, complain if not setup correctly. """

if app.env not in (
def check_app_env(env: Optional[str]):
if env not in (
"documentation",
"development",
"testing",
"staging",
"production",
):
print(
'Flask(flexmeasures) environment needs to be either "documentation", "development", "testing", "staging" or "production".'
f'Flask(flexmeasures) environment needs to be either "documentation", "development", "testing", "staging" or "production". It currently is "{env}".'
)
sys.exit(2)


def read_config(app: Flask, custom_path_to_config: Optional[str]):
"""Read configuration from various expected sources, complain if not setup correctly. """

check_app_env(app.env)

# First, load default config settings
app.config.from_object(
"flexmeasures.utils.config_defaults.%sConfig" % camelize(app.env)
)

# Now, potentially overwrite those from config file
# Now, potentially overwrite those from config file or environment variables

# These two locations are possible (besides the custom path)
path_to_config_home = str(Path.home().joinpath(".flexmeasures.cfg"))
path_to_config_instance = os.path.join(app.instance_path, "flexmeasures.cfg")
if not app.testing: # testing runs completely on defaults
# If no custom path is given, this will try home dir first, then instance dir

# Don't overwrite when testing (that should run completely on defaults)
if not app.testing:
used_path_to_config = read_custom_config(
app, custom_path_to_config, path_to_config_home, path_to_config_instance
)
read_required_env_vars(app)

# Check for missing values.
# Documentation runs fine without them.
Expand Down Expand Up @@ -107,9 +114,16 @@ def read_config(app: Flask, custom_path_to_config: Optional[str]):


def read_custom_config(
app, suggested_path_to_config, path_to_config_home, path_to_config_instance
app: Flask, suggested_path_to_config, path_to_config_home, path_to_config_instance
) -> str:
""" read in a custom config file or env vars. Return the path to the config file."""
"""
Read in a custom config file and env vars.
For the config, there are two fallback options, tried in a specific order:
If no custom path is suggested, we'll try the path in the home dir first,
then in the instance dir.
Return the path to the config file.
"""
if suggested_path_to_config is not None and not os.path.exists(
suggested_path_to_config
):
Expand All @@ -121,16 +135,23 @@ def read_custom_config(
path_to_config = path_to_config_instance
else:
path_to_config = suggested_path_to_config
app.logger.info(f"Loading config from {path_to_config} ...")
try:
app.config.from_pyfile(path_to_config)
except FileNotFoundError:
pass
# Finally, all required variables can be set as env var:
for req_var in required:
app.config[req_var] = os.getenv(req_var, app.config.get(req_var, None))
app.logger.warning(
f"File {path_to_config} could not be found! (work dir is {os.getcwd()})"
)
app.logger.warning(f"File exists: {os.path.exists(path_to_config)}")
return path_to_config


def read_required_env_vars(app: Flask):
""" All required variables and the plugins can be set as env var"""
for var in required:
app.config[var] = os.getenv(var, app.config.get(var, None))


def are_required_settings_complete(app) -> bool:
"""
Check if all settings we expect are not None. Return False if they are not.
Expand Down
1 change: 1 addition & 0 deletions setup.py
Expand Up @@ -10,6 +10,7 @@ def load_requirements(use_case):
if not req.strip() == ""
and not req.strip().startswith("#")
and not req.strip().startswith("-c")
and not req.strip().startswith("--find-links")
]
return reqs

Expand Down

0 comments on commit 3a3506f

Please sign in to comment.