Dotenv files¶
Overview¶
Collections of environment variables are stored in files commonly named .env and called "dotenv" files. The fastenv package provides methods for reading and writing these files.
Getting started¶
To get started, let's set up a virtual environment and install fastenv from the command line. If you've been through the environment variable docs, you're all set.
Setting up a virtual environment
python3 -m venv .venv
. .venv/bin/activate
python -m pip install fastenv
We'll work with an example .env file that contains variables in various formats. Copy the code block below using the "Copy to clipboard" icon in the top right of the code block, paste the contents into a new file in your text editor, and save it as .env
.
Example .env file
# .env
AWS_ACCESS_KEY_ID_EXAMPLE=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY_EXAMPLE=wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLE
CSV_VARIABLE=comma,separated,value
EMPTY_VARIABLE=''
# comment
INLINE_COMMENT=no_comment # inline comment
JSON_EXAMPLE='{"array": [1, 2, 3], "exponent": 2.99e8, "number": 123}'
PASSWORD='64w2Q$!&,,[EXAMPLE'
QUOTES_AND_WHITESPACE='text and spaces'
URI_TO_DIRECTORY='~/dev'
URI_TO_S3_BUCKET=s3://mybucket/.env
URI_TO_SQLITE_DB=sqlite:////path/to/db.sqlite
URL_EXAMPLE=https://start.duckduckgo.com/
These environment variables are formatted as described in the environment variable docs.
Loading a .env file¶
Files can be loaded with await fastenv.load_dotenv()
. File I/O is implemented with AnyIO, and the function returns a DotEnv
instance.
Asynchronous functions
You'll see some functions in this section defined with async def
.
Standard Python functions defined with def
are synchronous. Synchronous Python programs execute one step at a time. Python's global interpreter lock (GIL) blocks the next steps until the current step is done.
When functions are defined with async def
instead of def
, they become coroutines. These coroutines can run asynchronously, meaning that many steps can run at the same time without blocking the others, and the Python program can await
each coroutine. Asynchronous coroutines require special consideration in Python. For example, in order to use await
, the statement has to be inside of an async def
coroutine, and a method like asyncio.run()
has to be used to run the program.
See the Python standard library asyncio
docs for more details, and the FastAPI docs for some additional explanation and context.
The fastenv package uses AnyIO for its asynchronous functions. AnyIO uses similar syntax to asyncio
, such as anyio.run()
instead of asyncio.run()
, but offers many additional features.
If you're working with async-enabled web server tools like Uvicorn, Starlette, and FastAPI, you don't need to include the anyio.run()
part. It will be handled for you automatically when you start your server.
See the Trio docs for an informative justification of asynchronous file I/O.
The example below demonstrates how this works. Note that this is written as a script, not a REPL session. Save the script as example.py
in the same directory as the .env
file, then run the script from within the virtual environment.
Loading a .env file into a DotEnv
model
#!/usr/bin/env python3
# example.py
import anyio
import fastenv
async def load_my_dotenv(filename: str = ".env") -> fastenv.DotEnv:
dotenv = await fastenv.load_dotenv(filename)
print(dotenv.source)
print(dict(dotenv))
return dotenv
if __name__ == "__main__":
anyio.run(load_my_dotenv)
.venv ❯ python example.py
# output formatted for clarity
/Users/brendon/dev/fastenv-docs/.env
{
'AWS_ACCESS_KEY_ID_EXAMPLE': 'AKIAIOSFODNN7EXAMPLE',
'AWS_SECRET_ACCESS_KEY_EXAMPLE': 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLE',
'CSV_VARIABLE': 'comma,separated,value',
'EMPTY_VARIABLE': '',
'INLINE_COMMENT': 'no_comment',
'JSON_EXAMPLE': '{"array": [1, 2, 3], "exponent": 2.99e8, "number": 123}',
'PASSWORD': '64w2Q$!&,,[EXAMPLE',
'QUOTES_AND_WHITESPACE': 'text and spaces',
'URI_TO_DIRECTORY': '~/dev',
'URI_TO_S3_BUCKET': 's3://mybucket/.env',
'URI_TO_SQLITE_DB': 'sqlite:////path/to/db.sqlite',
'URL_EXAMPLE': 'https://start.duckduckgo.com/'
}
Comments were removed automatically, and each KEY=value
string was converted into a "KEY": "value"
pair in the dictionary. Each variable from the .env file was set as an environment variable for the Python program to use. The dotenv.source
attribute shows the path to the .env file that was loaded.
Finding a .env file with fastenv.find_dotenv()
If you're not sure of the exact path to the .env file, fastenv can locate it for you. Adding the find_source=True
argument (await fastenv.load_dotenv(find_source=True)
) will instruct fastenv to look for a .env file using its find_dotenv
method. By default, it will look for a file named .env
, starting in the current working directory and walking upwards until a file with the given file is found. It will return the path to the file if found, or raise a FileNotFoundError
if not found.
If you like, you may also use the fastenv.find_dotenv
method on its own. It accepts a path to (or just the name of) the file.
Simplifying serialization with fastenv.dotenv_values()
In some cases, you may simply want a dictionary of the keys and values in a .env file, instead of the DotEnv
model itself. Rather than running await fastenv.load_dotenv()
and then dict(dotenv)
to serialize the model into a dictionary, as we did in the example above, consider await fastenv.dotenv_values()
, which will load a .env file and return the dictionary directly.
Sorting environment variables
The load_dotenv
, dotenv_values
, and dump_dotenv
methods offer a Boolean sort_dotenv
argument. If True
, environment variables in the result will be sorted.
Loading multiple .env files¶
fastenv.load_dotenv
can load more than one .env file into a single DotEnv
model. To see this, let's add another .env file named .env.override
.
Example .env file with overrides for local development
# .env.override
APPLICATION_ENVIRONMENT=local
CSV_VARIABLE=comma,separated,override
URL_EXAMPLE=https://github.com
This is a common scenario in software development. Applications will often have a .env file that is used for deployments, and developers will have additional .env files to override deployment configurations for local development environments.
Now, we will update our example.py
module to load both files. The order is important here. Values are set in left-to-right insertion order, so if the same variables are present in both files, values in the second file will override values in the first.
Loading multiple .env files into a DotEnv
model
#!/usr/bin/env python3
# example.py
import anyio
import fastenv
async def load_my_dotenv(filename: str = ".env") -> fastenv.DotEnv:
dotenv = await fastenv.load_dotenv(filename)
print(dotenv.source)
print(dict(dotenv))
return dotenv
async def load_my_dotenvs(*filenames: str) -> fastenv.DotEnv:
dotenv = await fastenv.load_dotenv(*filenames)
print(dotenv.source)
print(dict(dotenv))
return dotenv
if __name__ == "__main__":
# anyio.run(load_my_dotenv)
anyio.run(load_my_dotenvs, ".env", ".env.override")
.venv ❯ python example.py
# output formatted for clarity
[
Path('/Users/brendon/dev/fastenv-docs/.env'),
Path('/Users/brendon/dev/fastenv-docs/.env.override')
]
{
'AWS_ACCESS_KEY_ID_EXAMPLE': 'AKIAIOSFODNN7EXAMPLE',
'AWS_SECRET_ACCESS_KEY_EXAMPLE': 'wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLE',
'CSV_VARIABLE': 'comma,separated,override',
'EMPTY_VARIABLE': '',
'INLINE_COMMENT': 'no_comment',
'JSON_EXAMPLE': '{"array": [1, 2, 3], "exponent": 2.99e8, "number": 123}',
'PASSWORD': '64w2Q$!&,,[EXAMPLE',
'QUOTES_AND_WHITESPACE': 'text and spaces',
'URI_TO_DIRECTORY': '~/dev',
'URI_TO_S3_BUCKET': 's3://mybucket/.env',
'URI_TO_SQLITE_DB': 'sqlite:////path/to/db.sqlite',
'URL_EXAMPLE': 'https://github.com',
'APPLICATION_ENVIRONMENT': 'local'
}
There are now two source paths listed, our variables CSV_VARIABLE
and URL_EXAMPLE
have been updated with the values from .env.override
, and the new APPLICATION_ENVIRONMENT
variable has been loaded.
Dumping a DotEnv
instance to a .env file¶
We can also go in the opposite direction by using await fastenv.dump_dotenv()
to write a DotEnv
model out to a file. Under the hood, the DotEnv
class uses its __str__()
method to deserialize the DotEnv
instance into a string, which is then written to the file.
Let's update the example.py
script to not only load .env
, but also dump it back out to a different file, .env.dump
.
Dumping a DotEnv
instance to a .env file
#!/usr/bin/env python3
# example.py
import anyio
import fastenv
async def load_my_dotenv(filename: str = ".env") -> fastenv.DotEnv:
dotenv = await fastenv.load_dotenv(filename)
print(dotenv.source)
print(dict(dotenv))
return dotenv
async def load_my_dotenvs(*filenames: str) -> fastenv.DotEnv:
dotenv = await fastenv.load_dotenv(*filenames)
print(dotenv.source)
print(dict(dotenv))
return dotenv
async def load_and_dump_my_dotenvs(*filenames: str) -> fastenv.DotEnv:
dotenv = await fastenv.load_dotenv(*filenames)
await fastenv.dump_dotenv(dotenv, ".env.dump")
return dotenv
if __name__ == "__main__":
# anyio.run(load_my_dotenv)
# anyio.run(load_my_dotenvs, ".env", ".env.override")
anyio.run(load_and_dump_my_dotenvs, ".env", ".env.override")
Try running python example.py
again, then opening .env.dump
in a text editor. The new .env.dump
file should have the variables from the DotEnv
instance.
Exceptions and logging¶
Handling exceptions
The fastenv.load_dotenv()
, fastenv.dotenv_values()
, and fastenv.dump_dotenv()
methods offer a raise_exceptions
argument to manage exceptions.
Python's default behavior is to raise exceptions, and fastenv follows this convention, with its default raise_exceptions=True
. However, it may be preferable in some cases to fail silently instead of raising an exception. In these cases, raise_exceptions=False
can be used.
If exceptions are encountered, fastenv.load_dotenv(raise_exceptions=False)
will return an empty DotEnv()
instance, fastenv.dotenv_values(raise_exceptions=False)
will return an empty dictionary, and fastenv.dump_dotenv(raise_exceptions=False)
will simply return the path to the destination file.
Logging
fastenv will provide a small amount of logging when loading or dumping .env files. Successes will be logged at the logging.INFO
level, and errors will be logged at the logging.ERROR
level.
If you're managing your loggers individually in a logging configuration file, all fastenv logging uses the "fastenv"
logger. Logging can be disabled by adding {"loggers": {"fastenv": {"propagate": False}}}
to a logging configuration dictionary.