diff --git a/documentation/changelog.rst b/documentation/changelog.rst index b51fcfe824..0176027b98 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -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 `_] + * 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 `_] * 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 `_] diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 6be8b491e3..3e0fe5ddef 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -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.", diff --git a/flexmeasures/utils/config_utils.py b/flexmeasures/utils/config_utils.py index 8eb7cef2e5..4db8e9a2e3 100644 --- a/flexmeasures/utils/config_utils.py +++ b/flexmeasures/utils/config_utils.py @@ -4,6 +4,7 @@ from __future__ import annotations +import ast import os import re import sys @@ -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 ( @@ -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( @@ -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. diff --git a/flexmeasures/utils/tests/test_config_utils.py b/flexmeasures/utils/tests/test_config_utils.py index 80825ec397..b1b42d9625 100644 --- a/flexmeasures/utils/tests/test_config_utils.py +++ b/flexmeasures/utils/tests/test_config_utils.py @@ -1,3 +1,4 @@ +import logging from datetime import timedelta import pytest @@ -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 + )