From 5b459545d6c9fab7030be59297c681b6d452bed6 Mon Sep 17 00:00:00 2001 From: Brent O'Connor Date: Fri, 1 Sep 2023 17:06:14 -0500 Subject: [PATCH] Refactor how the initial .env is created --- config/env_writer.py | 120 ++++++++++++++++++++++++++++++++++ config/settings/_base.py | 56 +++++++++------- docker-compose.yml | 8 +++ justfile | 6 ++ scripts/create_initial_env.py | 13 ++++ scripts/start_new_project | 9 +-- 6 files changed, 182 insertions(+), 30 deletions(-) create mode 100644 config/env_writer.py create mode 100755 scripts/create_initial_env.py diff --git a/config/env_writer.py b/config/env_writer.py new file mode 100644 index 00000000..5032f359 --- /dev/null +++ b/config/env_writer.py @@ -0,0 +1,120 @@ +import shutil +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Optional + +import environs + + +class EnvWriter: + var_data: dict[str, Any] + write_dot_env_file: bool = False + base_dir: Path + + def __init__( + self, + base_dir: Optional[Path] = None, + read_dot_env_file: bool = True, + eager: bool = True, + expand_vars: bool = False, + ): + self.var_data = {} + self._env = environs.Env(eager=eager, expand_vars=expand_vars) + self.write_dot_env_file = self._env.bool("WRITE_DOT_ENV_FILE", default=False) + self._env = environs.Env(eager=eager, expand_vars=expand_vars) + self.base_dir = environs.Path(__file__).parent if base_dir is None else base_dir # type: ignore + if read_dot_env_file is True and self.write_dot_env_file is False: + self._env.read_env(str(self.base_dir.joinpath(".env"))) + + def _get_var( + self, + environs_instance, + var_type: str, + environ_args: tuple[Any, ...], + environ_kwargs: Optional[dict[str, Any]] = None, + ): + environ_kwargs = environ_kwargs or {} + help_text = environ_kwargs.pop("help_text", None) + initial = environ_kwargs.pop("initial", None) + + if self.write_dot_env_file is True: + self.var_data[environ_args[0]] = { + "type": var_type, + "default": environ_kwargs.get("default"), + "help_text": help_text, + "initial": initial, + } + + try: + return getattr(environs_instance, var_type)(*environ_args, **environ_kwargs) + except environs.EnvError as e: + if self.write_dot_env_file is False: + raise e + + def __call__(self, *args, **kwargs): + return self._get_var(self._env, var_type="str", environ_args=args, environ_kwargs=kwargs) + + def __getattr__(self, item): + allowed_methods = [ + "int", + "bool", + "str", + "float", + "decimal", + "list", + "dict", + "json", + "datetime", + "date", + "time", + "path", + "log_level", + "timedelta", + "uuid", + "url", + "enum", + "dj_db_url", + "dj_email_url", + "dj_cache_url", + ] + if item not in allowed_methods: + return AttributeError(f"'{type(self).__name__}' object has no attribute '{item}'") + + def _get_var(*args, **kwargs): + return self._get_var(self._env, var_type=item, environ_args=args, environ_kwargs=kwargs) + + return _get_var + + def write_env_file(self, env_file_path: Optional[Path] = None, overwrite_existing: bool = False): + if env_file_path is None: + env_file_path = self.base_dir.joinpath(".env") + + if env_file_path.exists() is True and overwrite_existing is False: + backup_path = f"{env_file_path}.{datetime.now().strftime('%Y%m%d%H%M%S')}" + shutil.copy(env_file_path, backup_path) + + with open(env_file_path, "w") as f: + env_str = ( + f"# This is an initial .env file generated on {datetime.now(timezone.utc).isoformat()}. Any environment variable with a default\n" # noqa: E501 + "# can be safely removed or commented out. Any variable without a default must be set.\n\n" + ) + for key, data in self.var_data.items(): + initial = data.get("initial", None) + val = "" + + if data["help_text"] is not None: + env_str += f"# {data['help_text']}\n" + env_str += f"# type: {data['type']}\n" + + if data["default"] is not None: + env_str += f"# default: {data['default']}\n" + + if initial is not None and val == "": + val = initial() + + if val == "" and data["default"] is not None: + env_str += f"# {key}={val}\n\n" + else: + env_str += f"{key}={val}\n\n" + + f.write(env_str) diff --git a/config/settings/_base.py b/config/settings/_base.py index 4d9f8293..01d301d2 100644 --- a/config/settings/_base.py +++ b/config/settings/_base.py @@ -1,8 +1,9 @@ +import base64 +import os import socket import environs - -env = environs.Env() +from config.env_writer import EnvWriter """ Django settings for config project. @@ -16,25 +17,28 @@ BASE_DIR = environs.Path(__file__).parent.parent.parent # type: ignore -READ_DOT_ENV_FILE = env.bool("READ_DOT_ENV_FILE", default=True) - -if READ_DOT_ENV_FILE is True: - env.read_env(str(BASE_DIR.joinpath(".env"))) +env = EnvWriter(base_dir=BASE_DIR, read_dot_env_file=False) # Quick-start development settings - unsuitable for production # See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/ # SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = env("SECRET_KEY") +SECRET_KEY = env( + "SECRET_KEY", + initial=lambda: base64.b64encode(os.urandom(60)).decode(), + help_text="Django's SECRET_KEY used to provide cryptographic signing.", +) # SECURITY WARNING: don't run with debug turned on in production! -DEBUG = env.bool("DEBUG", default=False) +DEBUG = env.bool("DEBUG", default=True, help_text="Set Django Debug mode to on or off") -ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=[]) -INTERNAL_IPS = env.list("INTERNAL_IPS", default=["127.0.0.1"]) +ALLOWED_HOSTS = env.list( + "ALLOWED_HOSTS", default=["127.0.0.1"], help_text="List of allowed hosts that this Django site can serve" +) +INTERNAL_IPS = env.list("INTERNAL_IPS", default=["127.0.0.1"], help_text="IPs allowed to run in debug mode") # Get the IP to use for Django Debug Toolbar when developing with docker -if env.bool("USE_DOCKER", default=False) is True: +if env.bool("USE_DOCKER", default=True, help_text="Used to set add the IP to INTERNAL_IPS for Docker Compose") is True: ip = socket.gethostbyname(socket.gethostname()) INTERNAL_IPS += [ip[:-1] + "1"] @@ -86,14 +90,15 @@ }, ] -WSGI_APPLICATION = env("WSGI_APPLICATION", default="config.wsgi.application") -DB_SSL_REQUIRED = env.bool("DB_SSL_REQUIRED", default=not DEBUG) +WSGI_APPLICATION = env("WSGI_APPLICATION", default="config.wsgi.application", help_text="WSGI application to use") # Database # See https://github.com/jacobian/dj-database-url for more examples DATABASES = { "default": env.dj_db_url( - "DATABASE_URL", default=f'sqlite:///{BASE_DIR.joinpath("db.sqlite")}', ssl_require=DB_SSL_REQUIRED + "DATABASE_URL", + default="postgres://postgres:@db:5432/postgres", + help_text="Database URL for connecting to database", ) } @@ -143,7 +148,11 @@ STORAGES = { "default": { - "BACKEND": env("DEFAULT_FILE_STORAGE", default="django.core.files.storage.FileSystemStorage"), + "BACKEND": env( + "DEFAULT_FILE_STORAGE", + default="django.core.files.storage.FileSystemStorage", + help_text="Default storage backend for media files", + ), }, "staticfiles": { "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", @@ -153,11 +162,13 @@ if STORAGES["default"]["BACKEND"].endswith("MediaS3Storage") is True: STORAGES["staticfiles"]["BACKEND"] = env("STATICFILES_STORAGE") - AWS_ACCESS_KEY_ID = env("AWS_ACCESS_KEY_ID") - AWS_SECRET_ACCESS_KEY = env("AWS_SECRET_ACCESS_KEY") - AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME") + AWS_ACCESS_KEY_ID = env("AWS_ACCESS_KEY_ID", help_text="AWS Access Key ID if using S3 storage backend") + AWS_SECRET_ACCESS_KEY = env("AWS_SECRET_ACCESS_KEY", help_text="AWS Secret Access Key if using S3 storage backend") + AWS_STORAGE_BUCKET_NAME = env( + "AWS_STORAGE_BUCKET_NAME", help_text="AWS Storage Bucket Name if using S3 storage backend" + ) AWS_DEFAULT_ACL = "public-read" - AWS_S3_REGION = env("AWS_S3_REGION", default="us-east-2") + AWS_S3_REGION = env("AWS_S3_REGION", default="us-east-2", help_text="AWS S3 Region if using S3 storage backend") AWS_S3_CUSTOM_DOMAIN = f"s3.{AWS_S3_REGION}.amazonaws.com/{AWS_STORAGE_BUCKET_NAME}" AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"} STATIC_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/static/" @@ -179,11 +190,11 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" # CACHE SETTINGS -CACHE_URL = env("REDIS_URL", default="redis://redis:6379/0") +REDIS_URL = env("REDIS_URL", default="redis://redis:6379/0", help_text="Redis URL for connecting to redis") CACHES = { "default": { "BACKEND": "django.core.cache.backends.redis.RedisCache", - "LOCATION": CACHE_URL, + "LOCATION": REDIS_URL, } } @@ -192,7 +203,7 @@ CRISPY_TEMPLATE_PACK = "bootstrap5" # CELERY SETTINGS -CELERY_BROKER_URL = env("CACHE_URL", CACHE_URL) +CELERY_BROKER_URL = REDIS_URL SESSION_ENGINE = "django.contrib.sessions.backends.cache" @@ -227,6 +238,7 @@ email = env.dj_email_url( "EMAIL_URL", default="smtp://skroob@planetspaceball.com:12345@smtp.planetspaceball.com:587/?ssl=True&_default_from_email=President%20Skroob%20%3Cskroob@planetspaceball.com%3E", + help_text="URL used for setting Django's email settings", ) DEFAULT_FROM_EMAIL = email["DEFAULT_FROM_EMAIL"] EMAIL_HOST = email["EMAIL_HOST"] diff --git a/docker-compose.yml b/docker-compose.yml index d74a661a..bb5b2df9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,8 @@ services: - postgres_data:/var/lib/postgresql/data/ ports: - "5432:5432" + env_file: + - .env environment: - POSTGRES_HOST_AUTH_METHOD=trust @@ -45,6 +47,8 @@ services: - db - redis + env_file: + - .env environment: USE_DOCKER: 'on' DJANGO_SETTINGS_MODULE: config.settings @@ -62,6 +66,8 @@ services: depends_on: - web + env_file: + - .env environment: DJANGO_SETTINGS_MODULE: config.settings @@ -83,6 +89,8 @@ services: ports: - "3000:3000" + env_file: + - .env environment: NODE_ENV: development diff --git a/justfile b/justfile index 756d477c..a7a36c08 100644 --- a/justfile +++ b/justfile @@ -40,6 +40,12 @@ reset := `tput -Txterm sgr0` @build_assets: {{ node_cmd_prefix }} npm run build +# Create an initial .env file +@create_env_file: + # Create an empty .env so that docker-compose doesn't fail + touch .env; + {{ python_cmd_prefix }} ./scripts/create_initial_env.py + # Format SASS/CSS code @format_sass: just _start_msg "Formatting SASS code using stylelint" diff --git a/scripts/create_initial_env.py b/scripts/create_initial_env.py new file mode 100755 index 00000000..661dd109 --- /dev/null +++ b/scripts/create_initial_env.py @@ -0,0 +1,13 @@ +#!/usr/bin/env python +import os + +from django.core.management.color import make_style + +os.environ.setdefault("WRITE_DOT_ENV_FILE", "True") + +from config import settings # noqa: E402 + +style = make_style() + +settings.env.write_env_file() +print(style.SUCCESS("Successfully created initial env file!")) diff --git a/scripts/start_new_project b/scripts/start_new_project index c8cc2e1f..a3b4d517 100755 --- a/scripts/start_new_project +++ b/scripts/start_new_project @@ -73,15 +73,8 @@ else cd $PROJECT_DIRECTORY fi -SECRET_KEY=$(python -c "import random; print(''.join(random.SystemRandom().choice('abcdefghijklmnopqrstuvwxyz0123456789%^&*(-_=+)') for i in range(50)))") -cat > .env <