Skip to content

Commit d5775c7

Browse files
Refactor Settings creation (#908)
* Refactor Settings creation * Ensure nested config handled consistently * Add changelog and update docs for breaking change in environment loading * Further corrections to docs * Docs and linting
1 parent a0d9104 commit d5775c7

File tree

7 files changed

+72
-89
lines changed

7 files changed

+72
-89
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,10 @@ Write the date in place of the "Unreleased" in the case a new version is release
1414
- Extract API key handling
1515
- Extract scope fetching and checking
1616
- Refactor router construction
17+
- Adjust environment loading
18+
- This is a breaking change if setting TILED_SERVER_SECRET_KEYS or TILED_ALLOW_ORIGINS,
19+
TILED_SERVER_SECRET_KEYS is now TILED_SECRET_KEYS and these fields now require passing a json
20+
list e.g. ``TILED_SECRET_KEYS='["one", "two"]'``
1721

1822

1923
## 0.1.0-b20 (2025-03-07)

docs/source/reference/authentication.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,7 @@ authentication:
242242
- "SECRET"
243243
```
244244
245-
or by setting the ``TILED_SERVER_SECRET_KEYS``.
245+
or by setting the ``TILED_SECRET_KEYS`` environment variable.
246246
247247
If you prefer, you can extract the keys from the environment like:
248248
@@ -261,10 +261,10 @@ authentication:
261261
- "OLD_SECRET"
262262
```
263263
264-
or set ``TILED_SERVER_SECRET_KEYS`` to ``;``-separated values, as in
264+
or set ``TILED_SECRET_KEYS`` as a json list, e.g.
265265
266266
```
267-
TILED_SERVER_SECRET_KEYS=NEW_SECRET;OLD_SECRET
267+
TILED_SECRET_KEYS='["NEW_SECRET", "OLD_SECRET"]'
268268
```
269269

270270
The first secret value is always used to *encode* new tokens, but all values are

docs/source/tutorials/plotly-integration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ data visualization tool.
66
1. Start the server in a way that accepts requests from the chart-studio frontend.
77

88
```
9-
TILED_ALLOW_ORIGINS="https://chart-studio.plotly.com" tiled serve pyobject --public tiled.examples.generated:tree
9+
TILED_ALLOW_ORIGINS='["https://chart-studio.plotly.com"]' tiled serve pyobject --public tiled.examples.generated:tree
1010
```
1111

1212
2. Navigate your browser to

tiled/authn_database/connection_pool.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from fastapi import Depends
22
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
33

4-
from ..server.settings import get_settings
4+
from ..server.settings import Settings, get_settings
55
from ..utils import ensure_specified_sql_driver
66

77
# A given process probably only has one of these at a time, but we
@@ -31,9 +31,9 @@ async def close_database_connection_pool(database_settings):
3131
await engine.dispose()
3232

3333

34-
async def get_database_engine(settings=Depends(get_settings)):
34+
async def get_database_engine(settings: Settings = Depends(get_settings)):
3535
# Special case for single-user mode
36-
if settings.database_uri is None:
36+
if settings.database_settings.uri is None:
3737
return None
3838
try:
3939
return _connection_pools[settings.database_settings]

tiled/server/app.py

Lines changed: 22 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@
5151
from .compression import CompressionMiddleware
5252
from .dependencies import get_root_tree
5353
from .router import get_router
54-
from .settings import get_settings
54+
from .settings import Settings, get_settings
5555
from .utils import (
5656
API_KEY_COOKIE_NAME,
5757
CSRF_COOKIE_NAME,
@@ -161,9 +161,9 @@ def build_app(
161161
if authentication.get("providers"):
162162
# Even if the deployment allows public, anonymous access, secret
163163
# keys are needed to generate JWTs for any users that do log in.
164-
if not (
165-
("secret_keys" in authentication)
166-
or ("TILED_SERVER_SECRET_KEYS" in os.environ)
164+
if (
165+
"secret_keys" not in authentication
166+
and "TILED_SECRET_KEYS" not in os.environ
167167
):
168168
raise UnscalableConfig(
169169
"""
@@ -175,7 +175,7 @@ def build_app(
175175
- SECRET
176176
...
177177
178-
or via the environment variable TILED_SERVER_SECRET_KEYS.""",
178+
or via the environment variable TILED_SECRET_KEYS.""",
179179
)
180180
# Multi-user authentication requires a database. We cannot fall
181181
# back to the default of an in-memory SQLite database in a
@@ -461,29 +461,31 @@ def override_get_settings():
461461
if server_settings.get(item) is not None:
462462
setattr(settings, item, server_settings[item])
463463
database = server_settings.get("database", {})
464-
if database.get("uri"):
465-
settings.database_uri = database["uri"]
466-
if database.get("pool_size"):
467-
settings.database_pool_size = database["pool_size"]
468-
if database.get("pool_pre_ping"):
469-
settings.database_pool_pre_ping = database["pool_pre_ping"]
470-
if database.get("max_overflow"):
471-
settings.database_max_overflow = database["max_overflow"]
472-
if database.get("init_if_not_exists"):
473-
settings.database_init_if_not_exists = database["init_if_not_exists"]
464+
if uri := database.get("uri"):
465+
settings.database_settings.uri = uri
466+
if pool_size := database.get("pool_size"):
467+
settings.database_settings.pool_size = pool_size
468+
if pool_pre_ping := database.get("pool_pre_ping"):
469+
settings.database_settings.pool_pre_ping = pool_pre_ping
470+
if max_overflow := database.get("max_overflow"):
471+
settings.database_settings.max_overflow = max_overflow
472+
if init_if_not_exists := database.get("init_if_not_exists"):
473+
settings.database_init_if_not_exists = init_if_not_exists
474474
if authentication.get("providers"):
475475
# If we support authentication providers, we need a database, so if one is
476476
# not set, use a SQLite database in memory. Horizontally scaled deployments
477477
# must specify a persistent database.
478-
settings.database_uri = settings.database_uri or "sqlite://"
478+
settings.database_settings.uri = (
479+
settings.database_settings.uri or "sqlite://"
480+
)
479481
return settings
480482

481483
async def startup_event():
482484
from .. import __version__
483485

484486
logger.info(f"Tiled version {__version__}")
485487
# Validate the single-user API key.
486-
settings = app.dependency_overrides[get_settings]()
488+
settings: Settings = app.dependency_overrides[get_settings]()
487489
single_user_api_key = settings.single_user_api_key
488490
API_KEY_MSG = """
489491
Here are two ways to generate a good API key:
@@ -535,7 +537,7 @@ async def startup_event():
535537
# client.context.app.state.root_tree
536538
app.state.root_tree = app.dependency_overrides[get_root_tree]()
537539

538-
if settings.database_uri is not None:
540+
if settings.database_settings.uri is not None:
539541
from sqlalchemy.ext.asyncio import AsyncSession
540542

541543
from ..alembic_utils import (
@@ -660,8 +662,8 @@ async def shutdown_event():
660662
for task in tasks.get("shutdown", []):
661663
await task()
662664

663-
settings = app.dependency_overrides[get_settings]()
664-
if settings.database_uri is not None:
665+
settings: Settings = app.dependency_overrides[get_settings]()
666+
if settings.database_settings.uri is not None:
665667
from ..authn_database.connection_pool import close_database_connection_pool
666668

667669
for task in app.state.tasks:

tiled/server/settings.py

Lines changed: 38 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,82 +1,59 @@
1-
import collections
21
import os
32
import secrets
43
from datetime import timedelta
54
from functools import cache
65
from typing import Any, List, Optional
76

8-
from pydantic_settings import BaseSettings
7+
from pydantic import Field
8+
from pydantic.dataclasses import dataclass
9+
from pydantic_settings import BaseSettings, SettingsConfigDict
910

10-
DatabaseSettings = collections.namedtuple(
11-
"DatabaseSettings", "uri pool_size pool_pre_ping max_overflow"
12-
)
11+
12+
# hashable cache key for use in tiled.authn_database.connection_pool
13+
@dataclass(unsafe_hash=True)
14+
class DatabaseSettings:
15+
uri: Optional[str] = None
16+
pool_size: int = 5
17+
pool_pre_ping: bool = True
18+
max_overflow: int = 5
1319

1420

1521
class Settings(BaseSettings):
22+
"""A BaseSettings object defining configuration for the tiled instance.
23+
For loading variables from the environment, prefix with TILED_ and see:
24+
https://docs.pydantic.dev/latest/concepts/pydantic_settings/#parsing-environment-variable-values
25+
"""
26+
1627
tree: Any = None
17-
allow_anonymous_access: bool = bool(
18-
int(os.getenv("TILED_ALLOW_ANONYMOUS_ACCESS", False))
19-
)
20-
allow_origins: List[str] = [
21-
item for item in os.getenv("TILED_ALLOW_ORIGINS", "").split() if item
22-
]
28+
allow_anonymous_access: bool = False
29+
allow_origins: List[str] = Field(default_factory=list)
2330
authenticator: Any = None
2431
# These 'single user' settings are only applicable if authenticator is None.
25-
single_user_api_key: str = os.getenv(
26-
"TILED_SINGLE_USER_API_KEY", secrets.token_hex(32)
27-
)
28-
single_user_api_key_generated: bool = not (
29-
"TILED_SINGLE_USER_API_KEY" in os.environ
30-
)
31-
# The TILED_SERVER_SECRET_KEYS may be a single key or a ;-separated list of
32-
# keys to support key rotation. The first key will be used for encryption. Each
33-
# key will be tried in turn for decryption.
34-
secret_keys: List[str] = os.getenv(
35-
"TILED_SERVER_SECRET_KEYS", secrets.token_hex(32)
36-
).split(";")
37-
access_token_max_age: timedelta = timedelta(
38-
seconds=int(os.getenv("TILED_ACCESS_TOKEN_MAX_AGE", 15 * 60)) # 15 minutes
39-
)
40-
refresh_token_max_age: timedelta = timedelta(
41-
seconds=int(
42-
os.getenv("TILED_REFRESH_TOKEN_MAX_AGE", 7 * 24 * 60 * 60)
43-
) # 7 days
44-
)
45-
session_max_age: Optional[timedelta] = timedelta(
46-
seconds=int(os.getenv("TILED_SESSION_MAX_AGE", 365 * 24 * 60 * 60)) # 365 days
47-
)
32+
single_user_api_key: str = secrets.token_hex(32)
33+
single_user_api_key_generated: bool = "TILED_SINGLE_USER_API_KEY" not in os.environ
34+
# The first key will be used for encryption. Each key will be tried in turn for decryption.
35+
secret_keys: List[str] = [secrets.token_hex(32)]
36+
access_token_max_age: timedelta = 15 * 60 # 15 minutes
37+
refresh_token_max_age: timedelta = 7 * 24 * 60 * 60 # 7 days
38+
session_max_age: timedelta = 365 * 24 * 60 * 60 # 365 days
4839
# Put a fairly low limit on the maximum size of one chunk, keeping in mind
4940
# that data should generally be chunked. When we implement async responses,
5041
# we can raise this global limit.
51-
response_bytesize_limit: int = int(
52-
os.getenv("TILED_RESPONSE_BYTESIZE_LIMIT", 300_000_000)
53-
) # 300 MB
54-
reject_undeclared_specs: bool = bool(
55-
int(os.getenv("TILED_REJECT_UNDECLARED_SPECS", 0))
56-
)
57-
database_uri: Optional[str] = os.getenv("TILED_DATABASE_URI")
58-
database_init_if_not_exists: bool = int(
59-
os.getenv("TILED_DATABASE_INIT_IF_NOT_EXISTS", False)
60-
)
61-
database_pool_size: Optional[int] = int(os.getenv("TILED_DATABASE_POOL_SIZE", 5))
62-
database_pool_pre_ping: Optional[bool] = bool(
63-
int(os.getenv("TILED_DATABASE_POOL_PRE_PING", 1))
64-
)
65-
database_max_overflow: Optional[int] = int(
66-
os.getenv("TILED_DATABASE_MAX_OVERFLOW", 5)
67-
)
42+
response_bytesize_limit: int = 300_000_000 # 300 MB
43+
reject_undeclared_specs: bool = False
44+
# "env_prefix does not apply to fields with alias"
45+
# https://docs.pydantic.dev/latest/concepts/pydantic_settings/#environment-variable-names
46+
database_settings: DatabaseSettings = Field(
47+
DatabaseSettings(), validation_alias="TILED_DATABASE"
48+
)
49+
database_init_if_not_exists: bool = False
6850
expose_raw_assets: bool = True
6951

70-
@property
71-
def database_settings(self):
72-
# The point of this alias is to return a hashable cache key for use in
73-
# the module tiled.authn_database.connection_pool.
74-
return DatabaseSettings(
75-
uri=self.database_uri,
76-
pool_size=self.database_pool_size,
77-
pool_pre_ping=self.database_pool_pre_ping,
78-
max_overflow=self.database_max_overflow,
79-
)
52+
model_config = SettingsConfigDict(
53+
env_prefix="TILED_",
54+
nested_model_default_partial_update=True,
55+
env_nested_delimiter="_",
56+
)
8057

8158

8259
@cache

web-frontend/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ example of what is possible.
99
Start a Tiled server in a way that allows connections from the React app. For example:
1010

1111
```
12-
TILED_ALLOW_ORIGINS=http://localhost:5173 tiled serve demo --public
12+
TILED_ALLOW_ORIGINS='["http://localhost:5173"]' tiled serve demo --public
1313
```
1414

1515
```

0 commit comments

Comments
 (0)