Skip to content

Commit

Permalink
Implement async file I/O with AnyIO
Browse files Browse the repository at this point in the history
- Add a `DotEnv.source` attribute, which will be populated with the path
  from which a .env file was read
- Add a `DotEnv.__str__()` method, which will deserialize `DotEnv`
  instances into strings for dumping to files
- Implement `find_dotenv`, `dump_dotenv`, `load_dotenv`, `dotenv_values`
- Add documentation explaining how to work with the above methods
- Update documentation comparing fastenv with python-dotenv
- Update pytest fixtures in conftest.py to use async file I/O with AnyIO
  • Loading branch information
br3ndonland committed Jul 24, 2021
1 parent 512b36d commit 9164f98
Show file tree
Hide file tree
Showing 7 changed files with 613 additions and 57 deletions.
2 changes: 0 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ _Unified environment variable and settings management for FastAPI and beyond_
[![ci](https://github.com/br3ndonland/fastenv/workflows/ci/badge.svg)](https://github.com/br3ndonland/fastenv/actions/workflows/ci.yml)
[![codecov](https://codecov.io/gh/br3ndonland/fastenv/branch/main/graph/badge.svg?token=WDNHES5OYR)](https://codecov.io/gh/br3ndonland/fastenv)

🚧 UNDER CONSTRUCTION - NOT FOR PRODUCTION 🚧

## Description

[Environment variables](https://en.wikipedia.org/wiki/Environment_variable) are key-value pairs provided to the operating system with syntax like `VARIABLE_NAME=value`. Collections of environment variables are stored in files commonly named _.env_ and called "dotenv" files. The Python standard library `os` module provides tools for reading environment variables, such as `os.getenv("VARIABLE_NAME")`, but only handles strings, and doesn't include tools for file I/O. Additional logic is therefore needed to load environment variables from files before they can be read by Python, and to convert variables from strings to other Python types.
Expand Down
101 changes: 95 additions & 6 deletions docs/comparisons.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,13 +65,102 @@ _pydantic_ offers a [`BaseSettings` model](https://pydantic-docs.helpmanual.io/u

## python-dotenv

- [python-dotenv](https://github.com/theskumar/python-dotenv) is a package for loading _.env_ files and setting environment variables. It was [started](https://github.com/theskumar/python-dotenv/commit/5fc02b7303e8854243970e12564f2433da7a1f7f) by Django co-creator Jacob Kaplan-Moss in 2013, and was originally called django-dotenv. It is used by [Uvicorn](https://www.uvicorn.org/) and _[pydantic](https://pydantic-docs.helpmanual.io/usage/settings/)_, and suggested in the [FastAPI docs](https://fastapi.tiangolo.com/advanced/settings/).
- Its primary data structure, `dotenv.main.DotEnv`, inherits from `object`. As a result, it requires its own mapping mathods (`dict`, `get_key`, `set_key`, `unset_key`) that could be easily eliminated by inheriting from a mapping data structure such as `collections.abc.MutableMapping`.
- Other methods have confusing, counter-intuitive APIs. For example, the `load_dotenv()` function is supposed to "Parse a _.env_ file and then load all the variables found as environment variables," according to its docstring. However, the function always returns `True`, even if no _.env_ file is found or no environment variables are set, because of `DotEnv.set_as_environment_variables()`. Furthermore, this confusing behavior is not documented, because, as the maintainer [commented](https://github.com/theskumar/python-dotenv/issues/164#issuecomment-494750043), "The return value of `load_dotenv` is undocumented as I was planning to do something useful with it, but could not settle down to one."
- Loads files with the synchronous `open()` built-in function. Asyncio support is not provided.
[python-dotenv](https://github.com/theskumar/python-dotenv) is a package for loading _.env_ files and setting environment variables. It was [started](https://github.com/theskumar/python-dotenv/commit/5fc02b7303e8854243970e12564f2433da7a1f7f) by Django co-creator Jacob Kaplan-Moss in 2013, and was originally called django-dotenv. It is used by [Uvicorn](https://www.uvicorn.org/) and _[pydantic](https://pydantic-docs.helpmanual.io/usage/settings/)_, and suggested in the [FastAPI docs](https://fastapi.tiangolo.com/advanced/settings/).

### Environment variables

- Its primary data structure, `dotenv.main.DotEnv`, inherits from `object`. As a result, it requires its own mapping methods (such as `dict()`) that could be obviated by inheriting from a mapping data structure such as `collections.abc.MutableMapping`.
- Other methods have confusing, counter-intuitive APIs. For example, the `load_dotenv()` function is supposed to "Parse a .env file and then load all the variables found as environment variables," according to its docstring. However, the function always returns `True`, even if no _.env_ file is found or no environment variables are set, because of `DotEnv.set_as_environment_variables()`. Furthermore, this confusing behavior is not documented, because, as the maintainer [commented](https://github.com/theskumar/python-dotenv/issues/164#issuecomment-494750043), "The return value of `load_dotenv` is undocumented as I was planning to do something useful with it, but could not settle down to one."

### File I/O

- Loads files with the synchronous `open()` built-in function. Async support is not provided.
- Does not integrate with object storage like AWS S3.
- Maintainers have not been receptive to improvements. See [theskumar/python-dotenv#263](https://github.com/theskumar/python-dotenv/pull/263) for context.
- Continues supporting Python 2 after its [end-of-life](https://www.python.org/doc/sunset-python-2/), so it has to use [Python 2 type comments](https://mypy.readthedocs.io/en/stable/python2.html) and other legacy cruft.

### Project maintenance

- Continued supporting Python 2 after its [end-of-life](https://www.python.org/doc/sunset-python-2/) (until 0.19.0), so it had to use [Python 2 type comments](https://mypy.readthedocs.io/en/stable/python2.html) and other legacy cruft.
- Maintainers have not been receptive to improvements (see [theskumar/python-dotenv#263](https://github.com/theskumar/python-dotenv/pull/263) for context).

### Comparing fastenv and python-dotenv

#### `DotEnv`

- Both fastenv and python-dotenv provide a `DotEnv` class for managing environment variables
- `fastenv.DotEnv` inherits from `collections.abc.MutableMapping`, `dotenv.main.DotEnv` inherits from `object`
- fastenv includes `DotEnv` in its `__all__`, python-dotenv does not (it must be directly imported from `dotenv.main`)

#### `find_dotenv`

- fastenv: `await fastenv.find_dotenv()` (async)
- python-dotenv: `dotenv.find_dotenv()` (sync)
- Both fastenv and python-dotenv look for `".env"` by default
- Both python-dotenv and fastenv return `os.PathLike` objects
- fastenv raises `FileNotFoundError` exceptions by default if files are not found, python-dotenv does not

#### `load_dotenv`

- fastenv: `await fastenv.load_dotenv()` (async)
- python-dotenv: `dotenv.load_dotenv()` (sync)
- `fastenv.load_dotenv` logs the number of environment variables loaded, `dotenv.load_dotenv` does not
- `fastenv.load_dotenv` returns a `DotEnv` model, `dotenv.load_dotenv` returns `True` (even if no _.env_ file was found and no environment variables were loaded)

#### `find_dotenv` with `load_dotenv`

Users who would like to ensure their _.env_ files are found, and log the result, should be aware that `dotenv.load_dotenv`:

- Only calls `find_dotenv` if a file path is not provided, and does not pass an argument through to `find_dotenv` to raise exceptions if the file is not found
- Requires a call to `DotEnv.set_as_environment_variables` to actually set environment variables
- Does not provide logging
- Does not provide exception handling (its `verbose` argument does not necessarily raise an exception)
- Does not return the `DotEnv` instance created by `load_dotenv`, but always returns `True`, even if no _.env_ file is found or no environment variables are set

Something like the following is therefore needed instead of using `dotenv.load_dotenv`:

!!!example "Finding and loading a _.env_ file with python-dotenv"

```py
import logging

from dotenv import find_dotenv
from dotenv.main import DotEnv


def find_and_load_my_dotenv(env_file: str = ".env") -> DotEnv:
try:
logger = logging.getLogger()
source = find_dotenv(filename=env_file, raise_error_if_not_found=True)
dotenv = DotEnv(source, verbose=True)
dotenv.set_as_environment_variables()
logger.info(
f"Python-dotenv loaded {len(dotenv.dict())} variables from {env_file}"
)
return dotenv
except Exception as e:
logger.error(f"Error loading {env_file}: {e.__class__.__qualname__} {e}")
raise
```

The above effect can be accomplished with fastenv in a single call, `await fastenv.load_dotenv(find_source=True)`. This call to `fastenv.load_dotenv`:

- Finds the _.env_ file (`find_source=True`) with its `find_dotenv` method and the file name provided (`".env"` by default), logging and raising a `FileNotFoundError` if not found
- Sets environment variables automatically
- Logs successes and errors automatically
- Raises exceptions by default
- Returns a `DotEnv` instance

#### `dotenv_values`

- fastenv: `await fastenv.dotenv_values()` (async)
- python-dotenv: `dotenv.dotenv_values()` (sync)
- `fastenv.dotenv_values` offers a `find_dotenv` argument to find files before loading and returning values, `dotenv.dotenv_values` does not
- `fastenv.dotenv_values` offers a `raise_exceptions` argument to determine whether or not exceptions will be raised, `dotenv.dotenv_values` does not (its `verbose` argument does not necessarily raise an exception)
- `fastenv.dotenv_values` logs successes and errors automatically, `dotenv.dotenv_values` does not

#### Writing to _.env_ files

- fastenv: `await fastenv.dump_dotenv()` (async, and writes an entire `DotEnv` model to a file)
- python-dotenv: `dotenv.get_key()`, `dotenv.set_key()`, `dotenv.unset_key()` (sync, and can only write single variables to a file)

## Starlette

Expand Down
167 changes: 167 additions & 0 deletions docs/dotenv.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# 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](environment.md#getting-started), the only change here is installing the optional extras for working with files. File I/O is implemented with [AnyIO](https://anyio.readthedocs.io/en/stable/fileio.html).

!!!example "Setting up a virtual environment"

```sh
# set up a virtual environment and install fastenv
❯ python3 -m venv .venv
❯ . .venv/bin/activate
.venv ❯ python -m pip install fastenv[files]
```

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 "Example .env file"

```sh
# .env
AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE
AWS_SECRET_ACCESS_KEY=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:https://mybucket/.env
URI_TO_SQLITE_DB=sqlite:https:////path/to/db.sqlite
URL_EXAMPLE=https://start.duckduckgo.com/

```

These environment variables are formatted as described in the [environment variable docs](environment.md#tips).

## Loading a _.env_ file

Files can be loaded with `await fastenv.load_dotenv()`. This function returns a [`DotEnv`](environment.md) instance.

!!!info "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](https://docs.python.org/3/glossary.html#term-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](https://docs.python.org/3/library/asyncio-task.html). 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`](https://docs.python.org/3/library/asyncio-api-index.html) docs for more details, and the [FastAPI docs](https://fastapi.tiangolo.com/async/) for some additional explanation and context.

The fastenv package uses [AnyIO](https://anyio.readthedocs.io/en/stable/index.html) 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](https://www.uvicorn.org/), [Starlette](https://www.starlette.io/), and [FastAPI](https://fastapi.tiangolo.com/), 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](https://trio.readthedocs.io/en/stable/reference-io.html#asynchronous-filesystem-i-o) 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.

!!!example "Loading a _.env_ file into a `DotEnv` model"

```py
#!/usr/bin/env python3
# example.py
import anyio
import fastenv


async def load_my_dotenv() -> fastenv.DotEnv:
dotenv = await fastenv.load_dotenv()
print(dotenv.source)
print(dict(dotenv))
return dotenv


if __name__ == "__main__":
anyio.run(load_my_dotenv)
```

```sh
.venv ❯ python example.py

/Users/brendon/dev/fastenv-docs/.env
# output formatted for clarity
{
'AWS_ACCESS_KEY_ID': 'AKIAIOSFODNN7EXAMPLE',
'AWS_SECRET_ACCESS_KEY': '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:https://mybucket/.env',
'URI_TO_SQLITE_DB': 'sqlite:https:////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.

!!!tip "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, and the directory in which to start its search.

!!!tip "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.

## 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__()`](https://docs.python.org/3/reference/datamodel.html#object.__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`.

!!!example "Dumping a `DotEnv` instance to a _.env_ file"

```py
#!/usr/bin/env python3
# example.py
import anyio
import fastenv


async def load_my_dotenv() -> fastenv.DotEnv:
dotenv = await fastenv.load_dotenv()
print(dotenv.source)
print(dict(dotenv))
return dotenv


async def load_and_dump_my_dotenv() -> fastenv.DotEnv:
dotenv = await fastenv.load_dotenv()
await fastenv.dump_dotenv(dotenv, ".env.dump")
return dotenv


if __name__ == "__main__":
# anyio.run(load_my_dotenv)
anyio.run(load_and_dump_my_dotenv)
```

Try running `python example.py` again, then opening `.env.dump` in a text editor. The new `.env.dump` file should have the same contents as the original `.env` file.

## Exceptions

!!!tip "Handling exceptions"

The `fastenv.load_dotenv()`, `fastenv.dotenv_values()`, and `fastenv.dump_dotenv()` methods offer a `raise_exceptions` argument to manage [exceptions](https://docs.python.org/3/library/exceptions.html).

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.


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, and `fastenv.dump_dotenv(raise_exceptions=False)` will simply return the path to the destination file.

If exceptions are encountered, `fastenv.load_dotenv(raise_exceptions=False)` will return an empty `DotEnv()` instance, and `fastenv.dump_dotenv(raise_exceptions=False)` will simply return the path to the destination file that was provided.
Loading

0 comments on commit 9164f98

Please sign in to comment.