diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 00000000..363fed7c --- /dev/null +++ b/docs/config.md @@ -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 diff --git a/docs/database.md b/docs/database.md index c6a54c99..70ebab67 100644 --- a/docs/database.md +++ b/docs/database.md @@ -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() diff --git a/docs/release-notes.md b/docs/release-notes.md index 6ddc34cc..9b8840cc 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -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. diff --git a/mkdocs.yml b/mkdocs.yml index 16dac44d..7bbb0fde 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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' diff --git a/starlette/__init__.py b/starlette/__init__.py index c5981731..e94731c0 100644 --- a/starlette/__init__.py +++ b/starlette/__init__.py @@ -1 +1 @@ -__version__ = "0.9.3" +__version__ = "0.9.4" diff --git a/starlette/config.py b/starlette/config.py index 8adcc684..03571bdf 100644 --- a/starlette/config.py +++ b/starlette/config.py @@ -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): diff --git a/starlette/datastructures.py b/starlette/datastructures.py index 62c37ae9..50f0d5bc 100644 --- a/starlette/datastructures.py +++ b/starlette/datastructures.py @@ -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) diff --git a/starlette/middleware/database.py b/starlette/middleware/database.py index fc15f2df..3c64b42f 100644 --- a/starlette/middleware/database.py +++ b/starlette/middleware/database.py @@ -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) diff --git a/tests/test_config.py b/tests/test_config.py index 4fb2027d..1381772c 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -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('**********')" diff --git a/tests/test_datastructures.py b/tests/test_datastructures.py index 9560630e..666aabd0 100644 --- a/tests/test_datastructures.py +++ b/tests/test_datastructures.py @@ -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(