Version 0.9.4 (#251)

This commit is contained in:
Tom Christie 2018-12-05 16:38:45 +00:00 committed by GitHub
parent e525e40043
commit 08af34763d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 275 additions and 16 deletions

211
docs/config.md Normal file
View File

@ -0,0 +1,211 @@
Starlette encourages a strict separation of configuration from code,
following [the twelve-factor pattern][twelve-factor].
Configuration should be stored in environment variables, or in a ".env" file
that is not committed to source control.
**app.py**:
```python
from starlette.applications import Starlette
from starlette.config import Config
from starlette.datastructures import DatabaseURL, Secret
config = Config(".env")
DEBUG = config('DEBUG', cast=bool, default=False)
DATABASE_URL = config('DATABASE_URL', cast=DatabaseURL)
SECRET_KEY = config('SECRET_KEY', cast=Secret)
app = Starlette()
app.debug = DEBUG
...
```
**.env**:
```shell
# Don't commit this to source control.
# Eg. Include ".env" in your `.gitignore` file.
DEBUG=True
DATABASE_URL=postgresql://localhost/myproject
SECRET_KEY=43n080musdfjt54t-09sdgr
```
## Configuration precedence
The order in which configuration values are read is:
* From an environment variable.
* From the ".env" file.
* The default value given in `config`.
If none of those match, then `config(...)` will raise an error.
## Secrets
For sensitive keys, the `Secret` class is useful, since it prevents the value from
leaking out into tracebacks or logging.
To get the value of a `Secret` instance, you must explicitly cast it to a string.
You should only do this at the point at which the value is used.
```python
>>> from myproject import settings
>>> print(settings.SECRET_KEY)
Secret('**********')
>>> str(settings.SECRET_KEY)
'98n349$%8b8-7yjn0n8y93T$23r'
```
Similarly, the `URL` and `DatabaseURL` class will hide any password component
in their representations.
```python
>>> from myproject import settings
>>> print(settings.DATABASE_URL)
DatabaseURL('postgresql://admin:**********@192.168.0.8/my-application')
>>> str(settings.SECRET_KEY)
'98n349$%8b8-7yjn0n8y93T$23r'
```
## Reading or modifying the environment
In some cases you might want to read or modify the environment variables programmatically.
This is particularly useful in testing, where you may want to override particular
keys in the environment.
Rather than reading or writing from `os.environ`, you should use Starlette's
`environ` instance. This instance is a mapping onto the standard `os.environ`
that additionally protects you by raising an error if any environment variable
is set *after* the point that it has already been read by the configuration.
If you're using `pytest`, then you can setup any initial environment in
`tests/conftest.py`.
**tests/conftest.py**:
```python
from starlette.config import environ
environ['TESTING'] = 'TRUE'
```
## A full example
Structuring large applications can be complex. You need proper separation of
configuration and code, database isolation during tests, separate test and
production databases, etc...
Here we'll take a look at a complete example, that demonstrates how
we can start to structure an application.
First, let's keep our settings, our database table definitions, and our
application logic separated:
**myproject/settings.py**:
```python
from starlette.config import Config
from starlette.datastructures import DatabaseURL, Secret
config = Config(".env")
DEBUG = config('DEBUG', cast=bool, default=False)
TESTING = config('TESTING', cast=bool, default=False)
SECRET_KEY = config('SECRET_KEY', cast=Secret)
DATABASE_URL = config('DATABASE_URL', cast=DatabaseURL)
if TESTING:
DATABASE_URL = DATABASE_URL.replace(database='test_' + DATABASE_URL.database)
```
**myproject/tables.py**:
```python
import sqlalchemy
# Database table definitions.
metadata = sqlalchemy.MetaData()
organisations = sqlalchemy.Table(
...
)
```
**myproject/app.py**
```python
from starlette.applications import Starlette
from starlette.middleware.database import DatabaseMiddleware
from starlette.middleware.session import SessionMiddleware
from myproject import settings
app = Starlette()
app.debug = settings.DEBUG
app.add_middleware(
SessionMiddleware,
secret_key=settings.SECRET_KEY,
)
app.add_middleware(
DatabaseMiddleware,
database_url=settings.DATABASE_URL,
rollback_on_shutdown=settings.TESTING
)
@app.route('/', methods=['GET'])
async def homepage(request):
...
```
Now let's deal with our test configuration.
We'd like to create a new test database every time the test suite runs,
and drop it once the tests complete. We'd also like to ensure
**tests/conftest.py**:
```python
from starlette.config import environ
from starlette.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy_utils import database_exists, create_database
# This line would raise an error if we use it after 'settings' has been imported.
environ['TESTING'] = 'TRUE'
from myproject import settings
from myproject.app import app
from myproject.tables import metadata
@pytest.fixture(autouse=True, scope="session")
def setup_test_database():
"""
Create a clean test database every time the tests are run.
"""
engine = create_engine(settings.DATABASE_URL)
assert not database_exists(engine.url), 'Test database already exists. Aborting tests.'
create_database(settings.DATABASE_URL) # Create the test database.
metadata.create_all(engine) # Create the tables.
yield # Run the tests.
drop_database(settings.DATABASE_URL) # Drop the test database.
@pytest.fixture()
def client():
"""
Make a 'client' fixture available to test cases.
"""
# Our fixture is created within a context manager. This ensures that
# application startup and shutdown run for every test case.
#
# Because we've configured the DatabaseMiddleware with `rollback_on_shutdown`
# we'll get a complete rollback to the initial state after each test case runs.
async with TestClient(app) as test_client:
yield test_client
```
[twelve-factor]: https://12factor.net/config

View File

@ -14,10 +14,17 @@ Here's a complete example, that includes table definitions, installing the
import os
import sqlalchemy
from starlette.applications import Starlette
from starlette.config import Config
from starlette.middleware.database import DatabaseMiddleware
from starlette.responses import JSONResponse
# Configuration from environment variables or '.env' file.
config = Config('.env')
DATABASE_URL = config('DATABASE_URL')
# Database table definitions.
metadata = sqlalchemy.MetaData()
notes = sqlalchemy.Table(
@ -28,10 +35,13 @@ notes = sqlalchemy.Table(
sqlalchemy.Column("completed", sqlalchemy.Boolean),
)
# Application setup.
app = Starlette()
app.add_middleware(DatabaseMiddleware, database_url=os.environ['DATABASE_URL'])
app.add_middleware(DatabaseMiddleware, database_url=DATABASE_URL)
# Endpoints.
@app.route("/notes", methods=["GET"])
async def list_notes(request):
query = notes.select()

View File

@ -1,3 +1,13 @@
## 0.9.4
* Add `config.environ`.
* Add `datastructures.Secret`.
* Add `datastructures.DatabaseURL`.
## 0.9.3
* Add `config.Config(".env")`
## 0.9.2
* Add optional database support.

View File

@ -25,6 +25,7 @@ nav:
- Events: 'events.md'
- Background Tasks: 'background.md'
- Exceptions: 'exceptions.md'
- Configuration: 'config.md'
- Test Client: 'testclient.md'
- Release Notes: 'release-notes.md'

View File

@ -1 +1 @@
__version__ = "0.9.3"
__version__ = "0.9.4"

View File

@ -55,6 +55,11 @@ class Config:
if env_file is not None and os.path.isfile(env_file):
self.file_values = self._read_file(env_file)
def __call__(
self, key: str, cast: type = None, default: typing.Any = undefined
) -> typing.Any:
return self.get(key, cast, default)
def get(
self, key: str, cast: type = None, default: typing.Any = undefined
) -> typing.Any:
@ -65,7 +70,7 @@ class Config:
value = self.file_values[key]
return self._perform_cast(key, value, cast)
if default is not undefined:
return default
return self._perform_cast(key, default, cast)
raise KeyError("Config '%s' is missing, and has no default." % key)
def _read_file(self, file_name: str) -> typing.Dict[str, str]:
@ -80,7 +85,9 @@ class Config:
file_values[key] = value
return file_values
def _perform_cast(self, key: str, value: str, cast: type = None) -> typing.Any:
def _perform_cast(
self, key: str, value: typing.Any, cast: type = None
) -> typing.Any:
if cast is None or value is None:
return value
elif cast is bool and isinstance(value, str):

View File

@ -133,12 +133,22 @@ class URL:
class DatabaseURL(URL):
@property
def name(self) -> str:
def database(self) -> str:
return self.path.lstrip("/")
@property
def dialect(self) -> str:
return self.scheme.split("+")[0]
@property
def driver(self) -> str:
if "+" not in self.scheme:
return ""
return self.scheme.split("+", 1)[1]
def replace(self, **kwargs: typing.Any) -> "URL":
if "name" in kwargs:
kwargs["path"] = "/" + kwargs.pop("name")
if "database" in kwargs:
kwargs["path"] = "/" + kwargs.pop("database")
return super().replace(**kwargs)

View File

@ -29,7 +29,9 @@ class DatabaseMiddleware:
) -> DatabaseBackend:
if isinstance(database_url, str):
database_url = DatabaseURL(database_url)
assert database_url.scheme == "postgresql"
assert (
database_url.dialect == "postgresql"
), "Currently only postgresql is supported."
from starlette.database.postgres import PostgresBackend
return PostgresBackend(database_url)

View File

@ -20,14 +20,14 @@ def test_config(tmpdir):
config = Config(path, environ={"DEBUG": "true"})
DEBUG = config.get("DEBUG", cast=bool)
DATABASE_URL = config.get("DATABASE_URL", cast=DatabaseURL)
REQUEST_TIMEOUT = config.get("REQUEST_TIMEOUT", cast=int, default=10)
REQUEST_HOSTNAME = config.get("REQUEST_HOSTNAME")
SECRET_KEY = config.get("SECRET_KEY", cast=Secret)
DEBUG = config("DEBUG", cast=bool)
DATABASE_URL = config("DATABASE_URL", cast=DatabaseURL)
REQUEST_TIMEOUT = config("REQUEST_TIMEOUT", cast=int, default=10)
REQUEST_HOSTNAME = config("REQUEST_HOSTNAME")
SECRET_KEY = config("SECRET_KEY", cast=Secret)
assert DEBUG is True
assert DATABASE_URL.name == "database_name"
assert DATABASE_URL.database == "database_name"
assert REQUEST_TIMEOUT == 10
assert REQUEST_HOSTNAME == "example.com"
assert repr(SECRET_KEY) == "Secret('**********')"

View File

@ -46,10 +46,18 @@ def test_hidden_password():
def test_database_url():
u = DatabaseURL("postgresql://username:password@localhost/mydatabase")
u = u.replace(name="test_" + u.name)
assert u.name == "test_mydatabase"
u = u.replace(database="test_" + u.database)
assert u.database == "test_mydatabase"
assert str(u) == "postgresql://username:password@localhost/test_mydatabase"
u = DatabaseURL("postgresql://localhost/mydatabase")
assert u.dialect == "postgresql"
assert u.driver == ""
u = DatabaseURL("postgresql+asyncpg://localhost/mydatabase")
assert u.dialect == "postgresql"
assert u.driver == "asyncpg"
def test_url_from_scope():
u = URL(