Skip to content

Commit 5b45954

Browse files
committed
Refactor how the initial .env is created
1 parent a9e4017 commit 5b45954

File tree

6 files changed

+182
-30
lines changed

6 files changed

+182
-30
lines changed

config/env_writer.py

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import shutil
2+
from datetime import datetime, timezone
3+
from pathlib import Path
4+
from typing import Any, Optional
5+
6+
import environs
7+
8+
9+
class EnvWriter:
10+
var_data: dict[str, Any]
11+
write_dot_env_file: bool = False
12+
base_dir: Path
13+
14+
def __init__(
15+
self,
16+
base_dir: Optional[Path] = None,
17+
read_dot_env_file: bool = True,
18+
eager: bool = True,
19+
expand_vars: bool = False,
20+
):
21+
self.var_data = {}
22+
self._env = environs.Env(eager=eager, expand_vars=expand_vars)
23+
self.write_dot_env_file = self._env.bool("WRITE_DOT_ENV_FILE", default=False)
24+
self._env = environs.Env(eager=eager, expand_vars=expand_vars)
25+
self.base_dir = environs.Path(__file__).parent if base_dir is None else base_dir # type: ignore
26+
if read_dot_env_file is True and self.write_dot_env_file is False:
27+
self._env.read_env(str(self.base_dir.joinpath(".env")))
28+
29+
def _get_var(
30+
self,
31+
environs_instance,
32+
var_type: str,
33+
environ_args: tuple[Any, ...],
34+
environ_kwargs: Optional[dict[str, Any]] = None,
35+
):
36+
environ_kwargs = environ_kwargs or {}
37+
help_text = environ_kwargs.pop("help_text", None)
38+
initial = environ_kwargs.pop("initial", None)
39+
40+
if self.write_dot_env_file is True:
41+
self.var_data[environ_args[0]] = {
42+
"type": var_type,
43+
"default": environ_kwargs.get("default"),
44+
"help_text": help_text,
45+
"initial": initial,
46+
}
47+
48+
try:
49+
return getattr(environs_instance, var_type)(*environ_args, **environ_kwargs)
50+
except environs.EnvError as e:
51+
if self.write_dot_env_file is False:
52+
raise e
53+
54+
def __call__(self, *args, **kwargs):
55+
return self._get_var(self._env, var_type="str", environ_args=args, environ_kwargs=kwargs)
56+
57+
def __getattr__(self, item):
58+
allowed_methods = [
59+
"int",
60+
"bool",
61+
"str",
62+
"float",
63+
"decimal",
64+
"list",
65+
"dict",
66+
"json",
67+
"datetime",
68+
"date",
69+
"time",
70+
"path",
71+
"log_level",
72+
"timedelta",
73+
"uuid",
74+
"url",
75+
"enum",
76+
"dj_db_url",
77+
"dj_email_url",
78+
"dj_cache_url",
79+
]
80+
if item not in allowed_methods:
81+
return AttributeError(f"'{type(self).__name__}' object has no attribute '{item}'")
82+
83+
def _get_var(*args, **kwargs):
84+
return self._get_var(self._env, var_type=item, environ_args=args, environ_kwargs=kwargs)
85+
86+
return _get_var
87+
88+
def write_env_file(self, env_file_path: Optional[Path] = None, overwrite_existing: bool = False):
89+
if env_file_path is None:
90+
env_file_path = self.base_dir.joinpath(".env")
91+
92+
if env_file_path.exists() is True and overwrite_existing is False:
93+
backup_path = f"{env_file_path}.{datetime.now().strftime('%Y%m%d%H%M%S')}"
94+
shutil.copy(env_file_path, backup_path)
95+
96+
with open(env_file_path, "w") as f:
97+
env_str = (
98+
f"# This is an initial .env file generated on {datetime.now(timezone.utc).isoformat()}. Any environment variable with a default\n" # noqa: E501
99+
"# can be safely removed or commented out. Any variable without a default must be set.\n\n"
100+
)
101+
for key, data in self.var_data.items():
102+
initial = data.get("initial", None)
103+
val = ""
104+
105+
if data["help_text"] is not None:
106+
env_str += f"# {data['help_text']}\n"
107+
env_str += f"# type: {data['type']}\n"
108+
109+
if data["default"] is not None:
110+
env_str += f"# default: {data['default']}\n"
111+
112+
if initial is not None and val == "":
113+
val = initial()
114+
115+
if val == "" and data["default"] is not None:
116+
env_str += f"# {key}={val}\n\n"
117+
else:
118+
env_str += f"{key}={val}\n\n"
119+
120+
f.write(env_str)

config/settings/_base.py

Lines changed: 34 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1+
import base64
2+
import os
13
import socket
24

35
import environs
4-
5-
env = environs.Env()
6+
from config.env_writer import EnvWriter
67

78
"""
89
Django settings for config project.
@@ -16,25 +17,28 @@
1617

1718
BASE_DIR = environs.Path(__file__).parent.parent.parent # type: ignore
1819

19-
READ_DOT_ENV_FILE = env.bool("READ_DOT_ENV_FILE", default=True)
20-
21-
if READ_DOT_ENV_FILE is True:
22-
env.read_env(str(BASE_DIR.joinpath(".env")))
20+
env = EnvWriter(base_dir=BASE_DIR, read_dot_env_file=False)
2321

2422
# Quick-start development settings - unsuitable for production
2523
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
2624

2725
# SECURITY WARNING: keep the secret key used in production secret!
28-
SECRET_KEY = env("SECRET_KEY")
26+
SECRET_KEY = env(
27+
"SECRET_KEY",
28+
initial=lambda: base64.b64encode(os.urandom(60)).decode(),
29+
help_text="Django's SECRET_KEY used to provide cryptographic signing.",
30+
)
2931

3032
# SECURITY WARNING: don't run with debug turned on in production!
31-
DEBUG = env.bool("DEBUG", default=False)
33+
DEBUG = env.bool("DEBUG", default=True, help_text="Set Django Debug mode to on or off")
3234

33-
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=[])
34-
INTERNAL_IPS = env.list("INTERNAL_IPS", default=["127.0.0.1"])
35+
ALLOWED_HOSTS = env.list(
36+
"ALLOWED_HOSTS", default=["127.0.0.1"], help_text="List of allowed hosts that this Django site can serve"
37+
)
38+
INTERNAL_IPS = env.list("INTERNAL_IPS", default=["127.0.0.1"], help_text="IPs allowed to run in debug mode")
3539

3640
# Get the IP to use for Django Debug Toolbar when developing with docker
37-
if env.bool("USE_DOCKER", default=False) is True:
41+
if env.bool("USE_DOCKER", default=True, help_text="Used to set add the IP to INTERNAL_IPS for Docker Compose") is True:
3842
ip = socket.gethostbyname(socket.gethostname())
3943
INTERNAL_IPS += [ip[:-1] + "1"]
4044

@@ -86,14 +90,15 @@
8690
},
8791
]
8892

89-
WSGI_APPLICATION = env("WSGI_APPLICATION", default="config.wsgi.application")
90-
DB_SSL_REQUIRED = env.bool("DB_SSL_REQUIRED", default=not DEBUG)
93+
WSGI_APPLICATION = env("WSGI_APPLICATION", default="config.wsgi.application", help_text="WSGI application to use")
9194

9295
# Database
9396
# See https://github.com/jacobian/dj-database-url for more examples
9497
DATABASES = {
9598
"default": env.dj_db_url(
96-
"DATABASE_URL", default=f'sqlite:///{BASE_DIR.joinpath("db.sqlite")}', ssl_require=DB_SSL_REQUIRED
99+
"DATABASE_URL",
100+
default="postgres://postgres:@db:5432/postgres",
101+
help_text="Database URL for connecting to database",
97102
)
98103
}
99104

@@ -143,7 +148,11 @@
143148

144149
STORAGES = {
145150
"default": {
146-
"BACKEND": env("DEFAULT_FILE_STORAGE", default="django.core.files.storage.FileSystemStorage"),
151+
"BACKEND": env(
152+
"DEFAULT_FILE_STORAGE",
153+
default="django.core.files.storage.FileSystemStorage",
154+
help_text="Default storage backend for media files",
155+
),
147156
},
148157
"staticfiles": {
149158
"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
@@ -153,11 +162,13 @@
153162

154163
if STORAGES["default"]["BACKEND"].endswith("MediaS3Storage") is True:
155164
STORAGES["staticfiles"]["BACKEND"] = env("STATICFILES_STORAGE")
156-
AWS_ACCESS_KEY_ID = env("AWS_ACCESS_KEY_ID")
157-
AWS_SECRET_ACCESS_KEY = env("AWS_SECRET_ACCESS_KEY")
158-
AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME")
165+
AWS_ACCESS_KEY_ID = env("AWS_ACCESS_KEY_ID", help_text="AWS Access Key ID if using S3 storage backend")
166+
AWS_SECRET_ACCESS_KEY = env("AWS_SECRET_ACCESS_KEY", help_text="AWS Secret Access Key if using S3 storage backend")
167+
AWS_STORAGE_BUCKET_NAME = env(
168+
"AWS_STORAGE_BUCKET_NAME", help_text="AWS Storage Bucket Name if using S3 storage backend"
169+
)
159170
AWS_DEFAULT_ACL = "public-read"
160-
AWS_S3_REGION = env("AWS_S3_REGION", default="us-east-2")
171+
AWS_S3_REGION = env("AWS_S3_REGION", default="us-east-2", help_text="AWS S3 Region if using S3 storage backend")
161172
AWS_S3_CUSTOM_DOMAIN = f"s3.{AWS_S3_REGION}.amazonaws.com/{AWS_STORAGE_BUCKET_NAME}"
162173
AWS_S3_OBJECT_PARAMETERS = {"CacheControl": "max-age=86400"}
163174
STATIC_URL = f"https://{AWS_S3_CUSTOM_DOMAIN}/static/"
@@ -179,11 +190,11 @@
179190
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
180191

181192
# CACHE SETTINGS
182-
CACHE_URL = env("REDIS_URL", default="redis://redis:6379/0")
193+
REDIS_URL = env("REDIS_URL", default="redis://redis:6379/0", help_text="Redis URL for connecting to redis")
183194
CACHES = {
184195
"default": {
185196
"BACKEND": "django.core.cache.backends.redis.RedisCache",
186-
"LOCATION": CACHE_URL,
197+
"LOCATION": REDIS_URL,
187198
}
188199
}
189200

@@ -192,7 +203,7 @@
192203
CRISPY_TEMPLATE_PACK = "bootstrap5"
193204

194205
# CELERY SETTINGS
195-
CELERY_BROKER_URL = env("CACHE_URL", CACHE_URL)
206+
CELERY_BROKER_URL = REDIS_URL
196207

197208
SESSION_ENGINE = "django.contrib.sessions.backends.cache"
198209

@@ -227,6 +238,7 @@
227238
email = env.dj_email_url(
228239
"EMAIL_URL",
229240
default="smtp://[email protected]:[email protected]:587/?ssl=True&_default_from_email=President%20Skroob%20%[email protected]%3E",
241+
help_text="URL used for setting Django's email settings",
230242
)
231243
DEFAULT_FROM_EMAIL = email["DEFAULT_FROM_EMAIL"]
232244
EMAIL_HOST = email["EMAIL_HOST"]

docker-compose.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ services:
1212
- postgres_data:/var/lib/postgresql/data/
1313
ports:
1414
- "5432:5432"
15+
env_file:
16+
- .env
1517
environment:
1618
- POSTGRES_HOST_AUTH_METHOD=trust
1719

@@ -45,6 +47,8 @@ services:
4547
- db
4648
- redis
4749

50+
env_file:
51+
- .env
4852
environment:
4953
USE_DOCKER: 'on'
5054
DJANGO_SETTINGS_MODULE: config.settings
@@ -62,6 +66,8 @@ services:
6266
depends_on:
6367
- web
6468

69+
env_file:
70+
- .env
6571
environment:
6672
DJANGO_SETTINGS_MODULE: config.settings
6773

@@ -83,6 +89,8 @@ services:
8389
ports:
8490
- "3000:3000"
8591

92+
env_file:
93+
- .env
8694
environment:
8795
NODE_ENV: development
8896

justfile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ reset := `tput -Txterm sgr0`
4040
@build_assets:
4141
{{ node_cmd_prefix }} npm run build
4242

43+
# Create an initial .env file
44+
@create_env_file:
45+
# Create an empty .env so that docker-compose doesn't fail
46+
touch .env;
47+
{{ python_cmd_prefix }} ./scripts/create_initial_env.py
48+
4349
# Format SASS/CSS code
4450
@format_sass:
4551
just _start_msg "Formatting SASS code using stylelint"

scripts/create_initial_env.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
#!/usr/bin/env python
2+
import os
3+
4+
from django.core.management.color import make_style
5+
6+
os.environ.setdefault("WRITE_DOT_ENV_FILE", "True")
7+
8+
from config import settings # noqa: E402
9+
10+
style = make_style()
11+
12+
settings.env.write_env_file()
13+
print(style.SUCCESS("Successfully created initial env file!"))

scripts/start_new_project

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -73,15 +73,8 @@ else
7373
cd $PROJECT_DIRECTORY
7474
fi
7575

76-
SECRET_KEY=$(python -c "import random; print(''.join(random.SystemRandom().choice('abcdefghijklmnopqrstuvwxyz0123456789%^&*(-_=+)') for i in range(50)))")
77-
cat > .env <<EOF
78-
DEBUG=on
79-
SECRET_KEY='$SECRET_KEY'
80-
DATABASE_URL=postgres://postgres:@db:5432/postgres
81-
INTERNAL_IPS=127.0.0.1,0.0.0.0
82-
EOF
83-
8476
just remove_extra_files
77+
just create_env_file
8578
find ./public -name ".keep" | xargs rm -rf
8679

8780
echo ""

0 commit comments

Comments
 (0)