From 9d10f4f5b9fd2b91a95c4b41ffda991ec5cc88a9 Mon Sep 17 00:00:00 2001 From: Armin Friedl Date: Sun, 8 Nov 2020 14:20:41 +0100 Subject: [PATCH] .snipenv configuration Read configuration from: - environment - .snipenv - .snipenv.[SNIP_STAGE] --- .snipenv | 1 + .snipenv.local | 9 +++ Pipfile | 1 + Pipfile.lock | 36 +++++++++-- snip/__init__.py | 36 ++++++++++- snip/__main__.py | 4 ++ snip/config.local.py | 17 ------ snip/config.py | 139 ++++++++++++++++++++++++++++++++----------- 8 files changed, 184 insertions(+), 59 deletions(-) create mode 100644 .snipenv create mode 100644 .snipenv.local create mode 100644 snip/__main__.py delete mode 100644 snip/config.local.py diff --git a/.snipenv b/.snipenv new file mode 100644 index 0000000..a2a9f68 --- /dev/null +++ b/.snipenv @@ -0,0 +1 @@ +SNIP_STAGE=local diff --git a/.snipenv.local b/.snipenv.local new file mode 100644 index 0000000..a414b9e --- /dev/null +++ b/.snipenv.local @@ -0,0 +1,9 @@ +# Database +SNIP_DATABASE=sqlite +SNIP_DATABASE_URI=sqlite:////tmp/test.db +SNIP_DATABASE_TRACK_MODIFICATIONS=False + +# Flask +SNIP_FLASK_SECRET=secret +SNIP_FLASK_ENVIRONMENT=development +SNIP_FLASK_DEBUG=True diff --git a/Pipfile b/Pipfile index 6c2a05e..37b55bc 100644 --- a/Pipfile +++ b/Pipfile @@ -14,6 +14,7 @@ flask = "*" flask-sqlalchemy = "*" base58 = "*" flask-wtf = "*" +pydantic = "*" [requires] python_version = "3.8" diff --git a/Pipfile.lock b/Pipfile.lock index 77dd291..60f516d 100644 --- a/Pipfile.lock +++ b/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "5dc5b4443b620da8c4ee4052dea107005a6edec09fe78eae470daecc8d2fc570" + "sha256": "85510df90332ce819c847463e8d8ef3410d9d43774844df83e1a82d677498586" }, "pipfile-spec": 6, "requires": { @@ -107,6 +107,34 @@ ], "version": "==1.1.1" }, + "pydantic": { + "hashes": [ + "sha256:01f0291f4951580f320f7ae3f2ecaf0044cdebcc9b45c5f882a7e84453362420", + "sha256:0fe8b45d31ae53d74a6aa0bf801587bd49970070eac6a6326f9fa2a302703b8a", + "sha256:2182ba2a9290964b278bcc07a8d24207de709125d520efec9ad6fa6f92ee058d", + "sha256:2c1673633ad1eea78b1c5c420a47cd48717d2ef214c8230d96ca2591e9e00958", + "sha256:388c0c26c574ff49bad7d0fd6ed82fbccd86a0473fa3900397d3354c533d6ebb", + "sha256:4ba6b903e1b7bd3eb5df0e78d7364b7e831ed8b4cd781ebc3c4f1077fbcb72a4", + "sha256:6665f7ab7fbbf4d3c1040925ff4d42d7549a8c15fe041164adfe4fc2134d4cce", + "sha256:95d4410c4e429480c736bba0db6cce5aaa311304aea685ebcf9ee47571bfd7c8", + "sha256:a2fc7bf77ed4a7a961d7684afe177ff59971828141e608f142e4af858e07dddc", + "sha256:a3c274c49930dc047a75ecc865e435f3df89715c775db75ddb0186804d9b04d0", + "sha256:ab1d5e4d8de00575957e1c982b951bffaedd3204ddd24694e3baca3332e53a23", + "sha256:b11fc9530bf0698c8014b2bdb3bbc50243e82a7fa2577c8cfba660bcc819e768", + "sha256:b9572c0db13c8658b4a4cb705dcaae6983aeb9842248b36761b3fbc9010b740f", + "sha256:c68b5edf4da53c98bb1ccb556ae8f655575cb2e676aef066c12b08c724a3f1a1", + "sha256:c8200aecbd1fb914e1bd061d71a4d1d79ecb553165296af0c14989b89e90d09b", + "sha256:c9760d1556ec59ff745f88269a8f357e2b7afc75c556b3a87b8dda5bc62da8ba", + "sha256:ce2d452961352ba229fe1e0b925b41c0c37128f08dddb788d0fd73fd87ea0f66", + "sha256:dfaa6ed1d509b5aef4142084206584280bb6e9014f01df931ec6febdad5b200a", + "sha256:e5fece30e80087d9b7986104e2ac150647ec1658c4789c89893b03b100ca3164", + "sha256:f045cf7afb3352a03bc6cb993578a34560ac24c5d004fa33c76efec6ada1361a", + "sha256:f83f679e727742b0c465e7ef992d6da4a7e5268b8edd8fdaf5303276374bef52", + "sha256:fc21a37ff3f545de80b166e1735c4172b41b017948a3fb2d5e2f03c219eac50a" + ], + "index": "pypi", + "version": "==1.7.2" + }, "sqlalchemy": { "hashes": [ "sha256:009e8388d4d551a2107632921320886650b46332f61dc935e70c8bcf37d8e0d6", @@ -204,11 +232,11 @@ }, "ipython": { "hashes": [ - "sha256:2e22c1f74477b5106a6fb301c342ab8c64bb75d702e350f05a649e8cb40a0fb8", - "sha256:a331e78086001931de9424940699691ad49dfb457cea31f5471eae7b78222d5e" + "sha256:c987e8178ced651532b3b1ff9965925bfd445c279239697052561a9ab806d28f", + "sha256:cbb2ef3d5961d44e6a963b9817d4ea4e1fa2eb589c371a470fed14d8d40cbd6a" ], "index": "pypi", - "version": "==7.18.1" + "version": "==7.19.0" }, "ipython-genutils": { "hashes": [ diff --git a/snip/__init__.py b/snip/__init__.py index 20c9d23..b681f5c 100644 --- a/snip/__init__.py +++ b/snip/__init__.py @@ -1,13 +1,45 @@ +import os from flask import Flask + +########################### +# Read snip configuration # +########################### +from . import config +snip_config = config.configure() + + +############### +# Setup Flask # +############### + +# ENV and DEBUG are special in flask. They must be set to the environment before app init. +# https://flask.palletsprojects.com/en/1.1.x/config/#environment-and-debug-features +os.environ['FLASK_ENV'] = snip_config.SNIP_FLASK_ENVIRONMENT +os.environ['FLASK_DEBUG'] = str(snip_config.SNIP_FLASK_DEBUG) +# FLASK_SKIP_DOTENV currently only read from environment +os.environ['FLASK_SKIP_DOTENV'] = str(snip_config.SNIP_FLASK_SKIP_DOTENV) + app = Flask(__name__) -from . import config -config.configure("local") +app.config.update( + SECRET_KEY = snip_config.SNIP_FLASK_SECRET.get_secret_value(), + SQLALCHEMY_DATABASE_URI = snip_config.SNIP_DATABASE_URI, + SQLALCHEMY_TRACK_MODIFICATIONS = snip_config.SNIP_DATABASE_TRACK_MODIFICATION) + +################### +# Setup SQAlchemy # +################### from flask_sqlalchemy import SQLAlchemy db = SQLAlchemy(app) from . import models +db.create_all() + + +############## +# Setup snip # +############## from . import api from . import views diff --git a/snip/__main__.py b/snip/__main__.py new file mode 100644 index 0000000..8a1b435 --- /dev/null +++ b/snip/__main__.py @@ -0,0 +1,4 @@ +from . import app + +if __name__ == "__main__": + app.run() diff --git a/snip/config.local.py b/snip/config.local.py deleted file mode 100644 index ceb5448..0000000 --- a/snip/config.local.py +++ /dev/null @@ -1,17 +0,0 @@ -# FLASK -FLASK_DEBUG = True -FLASK_ENV = "development" - -# elisp: -# (->> (let (res) (dotimes (n 64 res) (setq res (cons (random (math-pow 2 8)) res)))) -# (--map (format "\\x%s" it)) -# (--reduce (concat acc it)) -# (kill-new)) -SECRET_KEY = b'\x90\xd6\x07\xa9\x84\x41\x15\x7c\x7b\x96\xd5\xb7\xa2\x52\xc3'\ - b'\x33\x3e\x40\xdf\x0a\x64\xdc\x27\x2b\x4a\x48\x7a\x88\xf6\x4c\xce\x9a'\ - b'\x5c\x5f\xc5\xc8\xa8\xa3\xbc\x28\xc0\x10\x6d\x54\xbb\x10\x58\x86\x9c'\ - b'\x68\x1b\xcc\xad\x68\xd0\xf3\x66\x65\xb5\xf4\x70\x9d\x0e\x3d' - -# SQL ALCHEMY -SQLALCHEMY_DATABASE_URI = "sqlite:////tmp/test.db" -SQLALCHEMY_TRACK_MODIFICATIONS = False diff --git a/snip/config.py b/snip/config.py index e0117d5..4524647 100644 --- a/snip/config.py +++ b/snip/config.py @@ -1,53 +1,120 @@ +""" +Snip Configuration +================== + +Configure snip based on settings found in the environment or environment +specific config files. The variables are set in the following order (later +settings override earlier ones): +- .snipenv +- .snipenv.[SNIP_STAGE] +- environment +- config.py (defaults) + +SNIP_STAGE is the only special value. It is taken from definitions in the +following order (later settings override earlier ones): +- .snipenv +- environment +- parameter in `config.configure` + +If no SNIP_STAGE is found in any of them, no stage specific configuration +(.snipenv.[SNIP_STAGE]) file is taken into account. + +All known configuration variables can be found in `SnipConfig`. +""" + import os import logging +from typing import Literal, Optional +from pathlib import Path -from . import app +from pydantic import BaseModel +from pydantic.types import SecretStr -logger = logging.getLogger(__name__) +log = logging.getLogger(__name__) -default_env = "local" +class SnipConfig(BaseModel): + # Database settings + SNIP_DATABASE: Literal['sqlite', 'postgres', 'mysql'] + SNIP_DATABASE_URI: str + SNIP_DATABASE_TRACK_MODIFICATION: bool = False -config_vars = [ - "SQLALCHEMY_DATABASE_URI", - "SQLALCHEMY_TRACK_MODIFICATIONS", - "SECRET_KEY", - "DEBUG", - "ENV" -] + # Flask settings + SNIP_FLASK_SECRET: SecretStr + SNIP_FLASK_ENVIRONMENT: Literal['development', 'production'] = 'production' + SNIP_FLASK_DEBUG: bool = False + SNIP_FLASK_SKIP_DOTENV: int = 1 + # Snip settings + SNIP_STAGE: Optional[str] -def find_env(): - env = (os.environ.get("SNIP_ENV") - or os.environ.get("FLASK_ENV") - or default_env) +def configure(stage: Optional[str] = None) -> SnipConfig: + config_dict = {} - if not env: - raise Exception("Could not determine environment") + # Read common configuration from .snipenv + config_dict.update(read_base_env()) - logger.info(f"Using environment {env}") + # bootstrap configuration, try to determine stage first + # stage can be set from parameter or environment + if not stage: + if os.getenv("SNIP_STAGE"): + stage = os.getenv("SNIP_STAGE") + elif config_dict.get("SNIP_STAGE"): + stage = config_dict.get("SNIP_STAGE") + # Read stage configuration from .snipenv.[stage] if set + if stage: config_dict.update(read_stage_env(stage)) + else: log.debug("Not using stage configuration") -def find_config_file(env): - config_file = f"config.{env}.py" - logger.info(f"Config file {config_file}") - return config_file + # Read variables from environment + config_dict.update(os.environ) + return SnipConfig(**config_dict) -def validate(config): - key = config.get("SECRET_KEY") - if isinstance(key, str): - key = key.encode("utf-8") - if not isinstance(key, bytes): - raise Exception("Secret key cannot be cast to bytes") - if len(key) < 64: # for blake2b hashing in snipper - raise Exception("Secret key must be 64 bytes long") +def read_base_env() -> dict: + """ Read base configuration from .snipenv if it exists """ + base_env_path = Path(".snipenv") + log.debug(f"Reading .snipenv file from {base_env_path.resolve()}") - logger.info("Configuration is valid") + try: + base_env_dict = read_env(base_env_path) + log.info("Setting base config from .snipenv") + return base_env_dict + except FileNotFoundError: + log.debug(".snipenv file not found") + return {} +def read_stage_env(stage: str) -> dict: + """ Reads the .snipenv file of the current stage """ + log.info(f"Using stage configuration for {stage}") + stage_env_name = ".snipenv."+stage + stage_env_path = Path(stage_env_name) + log.debug(f"Reading .snipenv file from {stage_env_path.resolve()}") -def configure(env=None): - logger.info("Configuring snip") - if not env: - env = find_env() - app.config.from_pyfile(find_config_file(env)) - validate(app.config) + try: + stage_env_dict = read_env(stage_env_path) + log.info(f"Setting stage config from {stage_env_name}") + return stage_env_dict + except FileNotFoundError: + log.error(f"Could not find stage configuration for {stage_env_name} in {os.path.curdir}") + raise + +def read_env(env_path: Path) -> dict: + """ Read env file from path into dict """ + if not env_path.exists() or not env_path.is_file(): + raise FileNotFoundError + + env_dict = dict() + with open(env_path) as f: + for (idx, line) in enumerate(f): + # skip empty lines + if not line.strip(): continue + # skip comment lines + if line.startswith('#'): continue + + try: + (k, v) = map(str.strip, line.split('=', maxsplit=1)) + env_dict[k] = v + except: + log.error(f"Cannot parse {env_path}:{idx}:{line}") + raise + return env_dict