config_utils.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232
  1. """
  2. Reading in configuration
  3. """
  4. from __future__ import annotations
  5. import os
  6. import sys
  7. import logging
  8. from datetime import datetime, timezone
  9. from logging.config import dictConfig as loggingDictConfig
  10. from pathlib import Path
  11. from flask import Flask
  12. from inflection import camelize
  13. import pandas as pd
  14. from flexmeasures.utils.config_defaults import (
  15. Config as DefaultConfig,
  16. required,
  17. warnable,
  18. )
  19. flexmeasures_logging_config = {
  20. "version": 1,
  21. "formatters": {
  22. "default": {"format": "[FLEXMEASURES][%(asctime)s] %(levelname)s: %(message)s"},
  23. "detail": {
  24. "format": "[FLEXMEASURES][%(asctime)s] %(levelname)s: %(message)s [logged in %(pathname)s:%(lineno)d]"
  25. },
  26. },
  27. "handlers": {
  28. "console": {
  29. "class": "logging.StreamHandler",
  30. "stream": sys.stdout,
  31. "formatter": "default",
  32. },
  33. "file": {
  34. "class": "logging.handlers.RotatingFileHandler",
  35. "level": "INFO",
  36. "formatter": "detail",
  37. "filename": "flexmeasures.log",
  38. "maxBytes": 10_000_000,
  39. "backupCount": 6,
  40. },
  41. },
  42. "root": {"level": "INFO", "handlers": ["console", "file"], "propagate": True},
  43. }
  44. def configure_logging():
  45. """Configure and register logging"""
  46. pd.options.display.expand_frame_repr = False # Don't wrap DataFrame representations
  47. loggingDictConfig(flexmeasures_logging_config)
  48. def check_app_env(env: str | None):
  49. if env not in (
  50. "documentation",
  51. "development",
  52. "testing",
  53. "staging",
  54. "production",
  55. ):
  56. print(
  57. f'Flexmeasures environment needs to be either "documentation", "development", "testing", "staging" or "production". It currently is "{env}".'
  58. )
  59. sys.exit(2)
  60. def read_config(app: Flask, custom_path_to_config: str | None):
  61. """Read configuration from various expected sources, complain if not setup correctly."""
  62. flexmeasures_env = DefaultConfig.FLEXMEASURES_ENV_DEFAULT
  63. if app.testing:
  64. flexmeasures_env = "testing"
  65. elif os.getenv("FLEXMEASURES_ENV", None):
  66. flexmeasures_env = os.getenv("FLEXMEASURES_ENV", None)
  67. elif os.getenv("FLASK_ENV", None):
  68. flexmeasures_env = os.getenv("FLASK_ENV", None)
  69. app.logger.warning(
  70. "'FLASK_ENV' is deprecated and replaced by FLEXMEASURES_ENV"
  71. " Change FLASK_ENV to FLEXMEASURES_ENV in the environment variables",
  72. )
  73. check_app_env(flexmeasures_env)
  74. # First, load default config settings
  75. app.config.from_object(
  76. "flexmeasures.utils.config_defaults.%sConfig" % camelize(flexmeasures_env)
  77. )
  78. # Now, potentially overwrite those from config file or environment variables
  79. # These two locations are possible (besides the custom path)
  80. path_to_config_home = str(Path.home().joinpath(".flexmeasures.cfg"))
  81. path_to_config_instance = os.path.join(app.instance_path, "flexmeasures.cfg")
  82. # Custom config: do not use any when testing (that should run completely on defaults)
  83. if not app.testing:
  84. used_path_to_config = read_custom_config(
  85. app, custom_path_to_config, path_to_config_home, path_to_config_instance
  86. )
  87. read_env_vars(app)
  88. else: # one exception: the ability to set where the test database is
  89. custom_test_db_uri = os.getenv("SQLALCHEMY_TEST_DATABASE_URI", None)
  90. if custom_test_db_uri:
  91. app.config["SQLALCHEMY_DATABASE_URI"] = custom_test_db_uri
  92. # Check for missing values.
  93. # Documentation runs fine without them.
  94. if not app.testing and flexmeasures_env != "documentation":
  95. if not are_required_settings_complete(app):
  96. if not os.path.exists(used_path_to_config):
  97. print(
  98. f"You can provide these settings ― as environment variables or in your config file (e.g. {path_to_config_home} or {path_to_config_instance})."
  99. )
  100. else:
  101. print(
  102. f"Please provide these settings ― as environment variables or in your config file ({used_path_to_config})."
  103. )
  104. sys.exit(2)
  105. missing_fields, config_warnings = get_config_warnings(app)
  106. if len(config_warnings) > 0:
  107. for warning in config_warnings:
  108. print(f"Warning: {warning}")
  109. print(f"You might consider setting {', '.join(missing_fields)}.")
  110. # Set the desired logging level on the root logger (controlling extension logging level)
  111. # and this app's logger.
  112. logging.getLogger().setLevel(app.config.get("LOGGING_LEVEL", "INFO"))
  113. app.logger.setLevel(app.config.get("LOGGING_LEVEL", "INFO"))
  114. # print("Logging level is %s" % logging.getLevelName(app.logger.level))
  115. app.config["START_TIME"] = datetime.now(timezone.utc)
  116. def read_custom_config(
  117. app: Flask, suggested_path_to_config, path_to_config_home, path_to_config_instance
  118. ) -> str:
  119. """
  120. Read in a custom config file and env vars.
  121. For the config, there are two fallback options, tried in a specific order:
  122. If no custom path is suggested, we'll try the path in the home dir first,
  123. then in the instance dir.
  124. Return the path to the config file.
  125. """
  126. if suggested_path_to_config is not None and not os.path.exists(
  127. suggested_path_to_config
  128. ):
  129. print(f"Cannot find config file {suggested_path_to_config}!")
  130. sys.exit(2)
  131. if suggested_path_to_config is None:
  132. path_to_config = path_to_config_home
  133. if not os.path.exists(path_to_config):
  134. path_to_config = path_to_config_instance
  135. else:
  136. path_to_config = suggested_path_to_config
  137. app.logger.info(f"Loading config from {path_to_config} ...")
  138. try:
  139. app.config.from_pyfile(path_to_config)
  140. except FileNotFoundError:
  141. app.logger.warning(
  142. f"File {path_to_config} could not be found! (work dir is {os.getcwd()})"
  143. )
  144. return path_to_config
  145. def read_env_vars(app: Flask):
  146. """
  147. Read in what we support as environment settings.
  148. At the moment, these are:
  149. - All required and warnable variables
  150. - Logging settings
  151. - access tokens
  152. - plugins (handled in plugin utils)
  153. - json compactness
  154. """
  155. for var in (
  156. required
  157. + list(warnable.keys())
  158. + [
  159. "LOGGING_LEVEL",
  160. "MAPBOX_ACCESS_TOKEN",
  161. "SENTRY_SDN",
  162. "FLEXMEASURES_PLUGINS",
  163. "FLEXMEASURES_JSON_COMPACT",
  164. ]
  165. ):
  166. app.config[var] = os.getenv(var, app.config.get(var, None))
  167. # DEBUG in env can come in as a string ("True") so make sure we don't trip here
  168. app.config["DEBUG"] = int(bool(os.getenv("DEBUG", app.config.get("DEBUG", False))))
  169. def are_required_settings_complete(app) -> bool:
  170. """
  171. Check if all settings we expect are not None. Return False if they are not.
  172. Printout helpful advice.
  173. """
  174. expected_settings = [s for s in get_configuration_keys(app) if s in required]
  175. missing_settings = [s for s in expected_settings if app.config.get(s) is None]
  176. if len(missing_settings) > 0:
  177. print(
  178. f"Missing the required configuration settings: {', '.join(missing_settings)}"
  179. )
  180. return False
  181. return True
  182. def get_config_warnings(app) -> tuple[list[str], list[str]]:
  183. """return missing settings and the warnings for them."""
  184. missing_settings = []
  185. config_warnings = []
  186. for setting, warning in warnable.items():
  187. if app.config.get(setting) is None:
  188. missing_settings.append(setting)
  189. config_warnings.append(warning)
  190. config_warnings = list(set(config_warnings))
  191. return missing_settings, config_warnings
  192. def get_configuration_keys(app) -> list[str]:
  193. """
  194. Collect all members of DefaultConfig who are not in-built fields or callables.
  195. """
  196. return [
  197. a
  198. for a in DefaultConfig.__dict__
  199. if not a.startswith("__") and not callable(getattr(DefaultConfig, a))
  200. ]