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

Add the ability to create an initial .env file when starting a new project #300

Closed
epicserve opened this issue Aug 29, 2023 · 8 comments
Closed

Comments

@epicserve
Copy link
Contributor

epicserve commented Aug 29, 2023

It would be helpful if there were a way to create an initial .env file with comments and any defaults set. If you have a project, it would be much nicer if, in the README, you don't have to copy and paste the following into an new .env file before starting the project, and instead, you can tell them to run a script. This also has the added benefit of not having to maintain the README with any changes to environment variables you're using in your settings.

I think this could be achieved by keeping track of all the calls made when initializing the settings. For example, the Env class could start keeping track of calls if an environment variable like WRITE_ENV_VARS is set. It could keep track of the calls in a dictionary. Maybe something like the following.

env_vars_used: dict = {
    'SECRET_KEY': {'type': 'str', 'default': None},
    'DEBUG': {'type': 'bool', 'default': False},
}

If the WRITE_ENV_VARS is set, it must ignore variables not set to collect all the variables in your settings. It could then use the env_vars_used dict to write out an .env file. Maybe something like the following.

# Type: str
SECRET_KEY=

# Type: bool
DEBUG=False

A bonus feature would be to add the keyword arguments initial and help_text to all the type methods. This would allow you to add help text in the comments and to set a callable for setting an initial value when creating .env files.

The following:

env.str(
    'SECRET_KEY',
    initial=lambda: base64.b64encode(os.urandom(60)).decode(),
    help_text='Django's SECRET_KEY used to provide cryptographic signing.'
)
env.bool('DEBUG', help_text='A boolean that turns on/off Django's debug mode.')

Could be used to write an initial .env like the following.

# Django's SECRET_KEY is used to provide cryptographic signing.
# Type: str
# Default: None
SECRET_KEY=lnqJi2vj+gB54SBKeVTmXOfs+KWVHtpiur9TrL/zwgY5iU6KJTZgFhAl2otzNBh82ySd9c567P2fEHiQ

# A boolean that turns on/off Django's debug mode.
# Type: bool
# Default: False
DEBUG=False

With Django this could be invoked in a management command as follows:

from django.conf import settings
from django.core.management.base import BaseCommand


class Command(BaseCommand):
    help = 'Description of your custom command'

    def handle(self, *args, **options):
        settings.env.write_env_file()
        self.stdout.write(self.style.SUCCESS('Initial .env file created.'))
@epicserve
Copy link
Contributor Author

I should add that I'm willing to contribute and help implement this feature.

@epicserve
Copy link
Contributor Author

I updated the issue to include an idea on how you would invoke writing to the file in a Django app.

@epicserve
Copy link
Contributor Author

I coded up the following POC that could be integrated in to the project.

import base64
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Union

import environs


class EnvWriter:
    var_data: dict
    write_dot_env_file: bool = False

    def __init__(self, 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 read_dot_env_file is True:
            self._env.read_env(str(self.base_dir.joinpath(".env")))

    def _get_var(self, environs_instance, var_type: str, environ_args: tuple = None, environ_kwargs: dict = None):
        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: Union[Path, str] = 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:
            env_file_path = f"{env_file_path}.{datetime.now().strftime('%Y%m%d%H%M%S')}"

        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"
                "# 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)


os.environ.setdefault("WRITE_DOT_ENV_FILE", "True")
env = EnvWriter(read_dot_env_file=False)

DEBUG = env.bool("DEBUG", default=False, help_text="Set Django Debug mode to on or off")
MAX_CONNECTIONS = env.int("MAX_CONNECTIONS", help_text="Maximum number of connections allowed to the database")
SECRET_KEY = env(
    "SECRET_KEY",
    initial=lambda: base64.b64encode(os.urandom(60)).decode(),
    help_text="Django's SECRET_KEY used to provide cryptographic signing.",
)
ALLOWED_HOSTS = env.list("ALLOWED_HOSTS", default=[], 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")
REDIS_URL = env("REDIS_URL", default="redis://redis:6379/0", help_text="Redis URL for connecting to redis")
DATABASES = {
    "default": env.dj_db_url(
        "DATABASE_URL",
        default=f"sqlite:///{env.base_dir}/db.sqlite",
        help_text="Database URL for connecting to database",
    )
}
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",
)

env.write_env_file(overwrite_existing=True)

@epicserve
Copy link
Contributor Author

The previous POC code generates the following .env file.

# This is an initial .env file generated on 2023-09-01T19:06:59.482643+00:00. Any environment variable with a default
# can be safely removed or commented out. Any variable without a default must be set.

# Set Django Debug mode to on or off
# type: bool
# default: False
# DEBUG=

# Maximum number of connections allowed to the database
# type: int
MAX_CONNECTIONS=

# Django's SECRET_KEY used to provide cryptographic signing.
# type: str
SECRET_KEY=JtMmaa4NOcM6H84tXWjzVQmNobPWvVY5nahPcS17U6zftSkm9G2yO/GDHDUj7Sr0Q0s0lOsS32L/1FY0

# List of allowed hosts that this Django site can serve
# type: list
# default: []
# ALLOWED_HOSTS=

# IPs allowed to run in debug mode
# type: list
# default: ['127.0.0.1']
# INTERNAL_IPS=

# Redis URL for connecting to redis
# type: str
# default: redis://redis:6379/0
# REDIS_URL=

# Database URL for connecting to database
# type: dj_db_url
# default: sqlite:////opt/project/db.sqlite
# DATABASE_URL=

# URL used for setting Django's email settings
# type: dj_email_url
# default: smtp://[email protected]:[email protected]:587/?ssl=True&_default_from_email=President%20Skroob%20%[email protected]%3E
# EMAIL_URL=

@epicserve
Copy link
Contributor Author

@sloria, any thoughts on this? I put a lot of thought and time into this.

I made another POC Pull Request into Django Base Site, if you want to look at that as well. It seems like something like this could be very helpful!

@sloria
Copy link
Owner

sloria commented Nov 8, 2023

Sorry for the delay in responding. I'd say that creating boilerplate .env files is out of the scope of environs. This library is unopinionated and is used in a wide variety of use cases (not only web applications), so any template .env file is bound to be overprescriptive. Keeping this feature in app boilerplates like the one you linked to makes a lot of sense to me!

@sloria sloria closed this as completed Nov 8, 2023
@epicserve
Copy link
Contributor Author

@sloria, How does adding a way to generate an .env file to any Python project, whether it's a web, CLI, or whatever type project make environs more opinionated?

Any python project that you have to manually figure out where each env() call is being made and determine the intent and reason behind the variable, creates a lot of extra wasted time, when all of that information could be be self documenting.

@sloria
Copy link
Owner

sloria commented Nov 13, 2023

environs scope is limited to parsing and validating envvars--it has no opinions on what those envvars are and what they're used for.

A command for writing a boilerplate .env file is the domain of project scaffolds.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants