Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor how the initial .env is created #445

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
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
120 changes: 120 additions & 0 deletions config/env_writer.py
Original file line number Diff line number Diff line change
@@ -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)
56 changes: 34 additions & 22 deletions config/settings/_base.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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"]

Expand Down Expand Up @@ -87,14 +91,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",
)
}

Expand Down Expand Up @@ -144,7 +149,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",
Expand All @@ -154,11 +163,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/"
Expand All @@ -180,11 +191,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,
}
}

Expand All @@ -193,7 +204,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"

Expand Down Expand Up @@ -228,6 +239,7 @@
email = env.dj_email_url(
"EMAIL_URL",
default="smtp://[email protected]:[email protected]:587/?ssl=True&_default_from_email=President%20Skroob%20%[email protected]%3E",
help_text="URL used for setting Django's email settings",
)
DEFAULT_FROM_EMAIL = email["DEFAULT_FROM_EMAIL"]
EMAIL_HOST = email["EMAIL_HOST"]
Expand Down
8 changes: 8 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ services:
- postgres_data:/var/lib/postgresql/data/
ports:
- "5432:5432"
env_file:
- .env
environment:
- POSTGRES_HOST_AUTH_METHOD=trust

Expand Down Expand Up @@ -45,6 +47,8 @@ services:
- db
- redis

env_file:
- .env
environment:
USE_DOCKER: 'on'
DJANGO_SETTINGS_MODULE: config.settings
Expand All @@ -62,6 +66,8 @@ services:
depends_on:
- web

env_file:
- .env
environment:
DJANGO_SETTINGS_MODULE: config.settings

Expand All @@ -83,6 +89,8 @@ services:
ports:
- "3000:3000"

env_file:
- .env
environment:
NODE_ENV: development

Expand Down
6 changes: 6 additions & 0 deletions justfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
13 changes: 13 additions & 0 deletions scripts/create_initial_env.py
Original file line number Diff line number Diff line change
@@ -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!"))
9 changes: 1 addition & 8 deletions scripts/start_new_project
Original file line number Diff line number Diff line change
Expand Up @@ -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 <<EOF
DEBUG=on
SECRET_KEY='$SECRET_KEY'
DATABASE_URL=postgres://postgres:@db:5432/postgres
INTERNAL_IPS=127.0.0.1,0.0.0.0
EOF

just remove_extra_files
just create_env_file
find ./public -name ".keep" | xargs rm -rf

echo ""
Expand Down