Skip to content
Open
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 documentation/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Infrastructure / Support

Bugfixes
-----------
* Stop warning on startup that python-dotenv could not parse a statement in ``flexmeasures.cfg``, which happened in every Docker container since v1.1.0 [see `PR #2845 <https://www.github.com/FlexMeasures/flexmeasures/pull/2845>`_]

* Apply storage losses to the starting stock of a state of charge shared by several devices, so its scheduled state of charge no longer drifts above the one the scheduler planned with [see `PR #2838 <https://www.github.com/FlexMeasures/flexmeasures/pull/2838>`_]

* Hovering anywhere over a session in the Charge Points chart shows its tooltip again, listing all of the session's timestamps, as it did before the move to ECharts, without highlighting another charger's session; also, a chart's tooltip no longer shows a point from a neighbouring subplot [see `PR #2829 <https://www.github.com/FlexMeasures/flexmeasures/pull/2829>`_]
Expand Down
2 changes: 1 addition & 1 deletion flexmeasures/ui/static/openapi-specs.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
},
"termsOfService": null,
"title": "FlexMeasures",
"version": "1.1.0"
"version": "1.1.1"
},
"externalDocs": {
"description": "FlexMeasures runs on the open source FlexMeasures technology. Read the docs here.",
Expand Down
39 changes: 34 additions & 5 deletions flexmeasures/utils/config_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from __future__ import annotations

import ast
import os
import re
import sys
Expand All @@ -15,7 +16,6 @@
from flask import Flask
from inflection import camelize
import isodate
from dotenv import dotenv_values
import pandas as pd

from flexmeasures.utils.config_defaults import (
Expand Down Expand Up @@ -80,14 +80,12 @@ def get_flexmeasures_env(app, cfg_location) -> str | None:
"""
flexmeasures_env: str | None = DefaultConfig.FLEXMEASURES_ENV_DEFAULT

cfg_config = dotenv_values(cfg_location)

if app.testing:
flexmeasures_env = "testing"
elif os.getenv("FLEXMEASURES_ENV", None):
flexmeasures_env = os.getenv("FLEXMEASURES_ENV", None)
elif cfg_config.get("FLEXMEASURES_ENV", None):
flexmeasures_env = cfg_config.get("FLEXMEASURES_ENV", None)
elif cfg_env := read_flexmeasures_env_from_cfg(cfg_location):
flexmeasures_env = cfg_env
elif os.getenv("FLASK_ENV", None):
flexmeasures_env = os.getenv("FLASK_ENV", None)
app.logger.warning(
Expand All @@ -98,6 +96,37 @@ def get_flexmeasures_env(app, cfg_location) -> str | None:
return flexmeasures_env or None


def read_flexmeasures_env_from_cfg(cfg_location: str | None) -> str | None:
"""Look up the FLEXMEASURES_ENV that a flexmeasures.cfg file sets, without running the file.

The file is Python, so it is parsed as Python, rather than as a dotenv file,
which made python-dotenv warn about every line that is not a plain assignment, such as an import.
Only a top-level assignment of a string literal counts; the last one wins, as it would when the file runs.
A missing or unparsable file sets nothing here; loading the config reports what is wrong with it.
"""
if cfg_location is None or not os.path.isfile(cfg_location):
return None
try:
tree = ast.parse(Path(cfg_location).read_text(), filename=cfg_location)
except (OSError, SyntaxError, ValueError):
return None
flexmeasures_env = None
for node in tree.body:
if isinstance(node, ast.Assign):
targets = node.targets
elif isinstance(node, ast.AnnAssign):
targets = [node.target]
else:
continue
if not any(
isinstance(t, ast.Name) and t.id == "FLEXMEASURES_ENV" for t in targets
):
continue
if isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
flexmeasures_env = node.value.value
return flexmeasures_env


def find_flexmeasures_cfg() -> str | None:
"""
Try to find a flexmeasures.cfg file in the home or instance directories.
Expand Down
54 changes: 54 additions & 0 deletions flexmeasures/utils/tests/test_config_utils.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import logging
from datetime import timedelta

import pytest
Expand Down Expand Up @@ -306,3 +307,56 @@ def test_configure_logging_keeps_loggers_that_already_exist(monkeypatch):
finally:
root.handlers[:] = root_handlers
root.setLevel(root_level)


def test_flexmeasures_env_from_a_python_cfg_raises_no_dotenv_warning(
tmp_path, monkeypatch, caplog
):
"""A flexmeasures.cfg is Python, so an import on its first line is fine, and should not set off python-dotenv's parse warning."""
cfg = tmp_path / "flexmeasures.cfg"
cfg.write_text(
"from datetime import timedelta\n"
"\n"
'FLEXMEASURES_ENV = "staging"\n'
"FLEXMEASURES_PLANNING_HORIZON = timedelta(days=2)\n"
"FLEXMEASURES_PLUGINS = [\n"
' "/app/plugin",\n'
"]\n"
)
monkeypatch.delenv("FLEXMEASURES_ENV", raising=False)
monkeypatch.delenv("FLASK_ENV", raising=False)
app = Flask("flexmeasures-test")

with caplog.at_level(logging.DEBUG):
assert config_utils.get_flexmeasures_env(app, str(cfg)) == "staging"
assert not [r for r in caplog.records if r.name.startswith("dotenv")]


@pytest.mark.parametrize(
"content, expected",
[
('FLEXMEASURES_ENV = "production"\n', "production"),
("FLEXMEASURES_ENV: str = 'staging'\n", "staging"),
# The last assignment wins, as it does when the file runs.
(
'FLEXMEASURES_ENV = "staging"\nFLEXMEASURES_ENV = "production"\n',
"production",
),
# Only a string literal counts: an expression is not evaluated, and is not mistaken for a string.
('import os\nFLEXMEASURES_ENV = os.environ.get("X", "staging")\n', None),
("SQLALCHEMY_DATABASE_URI = 'postgresql://x'\n", None),
("this is not python\n", None),
],
)
def test_read_flexmeasures_env_from_cfg(tmp_path, content, expected):
cfg = tmp_path / "flexmeasures.cfg"
cfg.write_text(content)
assert config_utils.read_flexmeasures_env_from_cfg(str(cfg)) == expected


def test_read_flexmeasures_env_from_missing_cfg(tmp_path):
assert config_utils.read_flexmeasures_env_from_cfg(None) is None
assert (
config_utils.read_flexmeasures_env_from_cfg(str(tmp_path / "missing.cfg"))
is None
)
Loading