mirror of https://github.com/encode/starlette.git
Version 0.9.4 (#251)
This commit is contained in:
parent
e525e40043
commit
08af34763d
|
@ -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
|
|
@ -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()
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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'
|
||||
|
||||
|
|
|
@ -1 +1 @@
|
|||
__version__ = "0.9.3"
|
||||
__version__ = "0.9.4"
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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('**********')"
|
||||
|
|
|
@ -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(
|
||||
|
|
Loading…
Reference in New Issue