aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniel Schadt <kingdread@gmx.de>2022-12-05 21:06:25 +0100
committerDaniel Schadt <kingdread@gmx.de>2022-12-05 21:06:25 +0100
commitd1f07803e39c1072127315821580eea3939fb51f (patch)
tree8c33f5093dde0cacfab79393ab1be523060813aa
parenta95a9768135bcaaa856a7cca98409a49250c96fa (diff)
downloadfietsboek-d1f07803e39c1072127315821580eea3939fb51f.tar.gz
fietsboek-d1f07803e39c1072127315821580eea3939fb51f.tar.bz2
fietsboek-d1f07803e39c1072127315821580eea3939fb51f.zip
parse/validate config with pydantic
This is the first step, in the next step, we should actually use request.config.
-rw-r--r--fietsboek/__init__.py17
-rw-r--r--fietsboek/config.py261
-rw-r--r--fietsboek/views/tileproxy.py18
-rw-r--r--poetry.lock70
-rw-r--r--pyproject.toml2
-rw-r--r--testing.ini6
6 files changed, 351 insertions, 23 deletions
diff --git a/fietsboek/__init__.py b/fietsboek/__init__.py
index d9077d5..5e342c5 100644
--- a/fietsboek/__init__.py
+++ b/fietsboek/__init__.py
@@ -15,7 +15,7 @@ from pyramid.i18n import default_locale_negotiator
from .security import SecurityPolicy
from .data import DataManager
from .pages import Pages
-from . import jinja2 as fiets_jinja2
+from . import jinja2 as mod_jinja2, config as mod_config
__VERSION__ = importlib_metadata.version('fietsboek')
@@ -53,6 +53,9 @@ def main(global_config, **settings):
"""
# pylint: disable=unused-argument, import-outside-toplevel, cyclic-import
from .views import tileproxy
+
+ parsed_config = mod_config.parse(settings)
+
if settings.get('session_key', '<EDIT THIS>') == '<EDIT THIS>':
raise ValueError("Please set a session signing key (session_key) in your settings!")
@@ -66,6 +69,9 @@ def main(global_config, **settings):
def redis_(request):
return redis.from_url(request.registry.settings["redis.url"])
+ def config_(_request):
+ return parsed_config
+
settings['enable_account_registration'] = asbool(
settings.get('enable_account_registration', 'false'))
settings['available_locales'] = aslist(
@@ -108,11 +114,12 @@ def main(global_config, **settings):
config.add_request_method(data_manager, reify=True)
config.add_request_method(pages, reify=True)
config.add_request_method(redis_, name="redis", reify=True)
+ config.add_request_method(config_, name="config", reify=True)
jinja2_env = config.get_jinja2_environment()
- jinja2_env.filters['format_decimal'] = fiets_jinja2.filter_format_decimal
- jinja2_env.filters['format_datetime'] = fiets_jinja2.filter_format_datetime
- jinja2_env.filters['local_datetime'] = fiets_jinja2.filter_local_datetime
- jinja2_env.globals['embed_tile_layers'] = fiets_jinja2.global_embed_tile_layers
+ jinja2_env.filters['format_decimal'] = mod_jinja2.filter_format_decimal
+ jinja2_env.filters['format_datetime'] = mod_jinja2.filter_format_datetime
+ jinja2_env.filters['local_datetime'] = mod_jinja2.filter_local_datetime
+ jinja2_env.globals['embed_tile_layers'] = mod_jinja2.global_embed_tile_layers
return config.make_wsgi_app()
diff --git a/fietsboek/config.py b/fietsboek/config.py
new file mode 100644
index 0000000..c6da878
--- /dev/null
+++ b/fietsboek/config.py
@@ -0,0 +1,261 @@
+"""Configuration parsing for Fietsboek.
+
+Fietsboek mostly relies on pyramid's INI parsing to get its configuration,
+however, there are quite a a few custom values that we add/need. Instead of
+manually sprinkling ``settings.get(...)`` all over the code, we'd rather just
+do it once at application startup, make sure that the values are well-formed,
+provide the user with good feedback if they aren't, and set the default values
+in a single place.
+
+Most of the logic is handled by pydantic_.
+
+.. _pydantic: https://pydantic-docs.helpmanual.io/
+"""
+# pylint: disable=no-name-in-module,no-self-argument,too-few-public-methods
+import logging
+import re
+import typing
+import urllib.parse
+from enum import Enum
+
+import pydantic
+from pydantic import (
+ BaseModel, Field, AnyUrl, DirectoryPath, validator, SecretStr,
+)
+from pyramid import settings
+from termcolor import colored
+
+LOGGER = logging.getLogger(__name__)
+
+KNOWN_PYRAMID_SETTINGS = {
+ "pyramid.reload_templates",
+ "pyramid.reload_assets",
+ "pyramid.debug_authorization",
+ "pyramid.debug_notfound",
+ "pyramid.debug_routematch",
+ "pyramid.prevent_http_cache",
+ "pyramid.prevent_cachebust",
+ "pyramid.debug_all",
+ "pyramid.reload_all",
+ "pyramid.default_locale_name",
+ "pyramid.includes",
+ "pyramid.tweens",
+ "pyramid.reload_resources",
+ "retry.attempts",
+}
+
+KNOWN_TILE_LAYERS = [
+ "osm", "osmde", "satellite", "opentopo", "topplusopen",
+ "opensea", "cycling", "hiking",
+]
+
+
+class ValidationError(Exception):
+ """Exception for malformed configurations.
+
+ This provides a nice str() representation that can be printed out.
+ """
+
+ def __init__(self, errors):
+ self.errors = errors
+
+ def __str__(self):
+ lines = ['']
+ for where, error in self.errors:
+ lines.append(colored(f'Error in {where}:', 'red'))
+ lines.append(str(error))
+ return "\n".join(lines)
+
+
+class LayerType(Enum):
+ """Enum to distinguish base layers and overlay layers."""
+ BASE = "base"
+ OVERLAY = "overlay"
+
+
+class LayerAccess(Enum):
+ """Enum discerning whether a layer is publicly accessible or restriced to
+ logged-in users.
+
+ Note that in the future, a finer-grained distinction might be possible.
+ """
+ PUBLIC = "public"
+ RESTRICTED = "restricted"
+
+
+class PyramidList(list):
+ """A pydantic field type that uses pyramid.settings.aslist as a parser."""
+
+ @classmethod
+ def __get_validators__(cls):
+ yield cls.validate
+
+ @classmethod
+ def validate(cls, value):
+ """Parses the list with pyramid.settings.aslist."""
+ if not isinstance(value, str):
+ raise TypeError("string required")
+ return settings.aslist(value)
+
+
+class TileLayerConfig(BaseModel):
+ """Object representing a single tile layer."""
+
+ layer_id: str
+ """ID of the layer."""
+
+ name: str
+ """Human-readable name of the layer."""
+
+ url: AnyUrl
+ """URL of the layer tiles (with placeholders)."""
+
+ layer_type: LayerType = Field(LayerType.BASE, alias="type")
+ """Type of the layer."""
+
+ zoom: int = 22
+ """Maximum zoom factor of the layer."""
+
+ attribution: str = ""
+ """Attribution of the layer copyright."""
+
+ access: LayerAccess = LayerAccess.PUBLIC
+ """Layer access restriction."""
+
+
+class Config(BaseModel):
+ """Object representing the Fietsboek configuration."""
+
+ sqlalchemy_url: str = Field(alias="sqlalchemy.url")
+ """SQLAlchemy URL."""
+
+ redis_url: str = Field(alias="redis.url")
+ """Redis URL."""
+
+ data_dir: DirectoryPath = Field(alias="fietsboek.data_dir")
+ """Fietsboek data directory."""
+
+ enable_account_registration: bool = False
+ """Enable registration of new accounts."""
+
+ session_key: str
+ """Session key."""
+
+ available_locales: PyramidList = ["en", "de"]
+ """Available locales."""
+
+ email_from: str = Field(alias="email.from")
+ """Email sender address."""
+
+ email_smtp_url: str = Field(alias="email.smtp_url")
+ """Email SMTP server."""
+
+ email_username: str = Field("", alias="email.username")
+ """SMTP username (optional)."""
+
+ email_password: SecretStr = Field("", alias="email.password")
+ """SMTP password (optional)."""
+
+ pages: PyramidList = Field([], alias="fietsboek.pages")
+ """Custom pages."""
+
+ default_tile_layers: PyramidList = Field(KNOWN_TILE_LAYERS,
+ alias="fietsboek.default_tile_layers")
+ """The subset of the default tile layers that should be enabled.
+
+ By default, that's all of them.
+ """
+
+ thunderforest_key: SecretStr = Field("", alias="thunderforest.api_key")
+ """API key for the Thunderforest integration."""
+
+ thunderforest_maps: PyramidList = Field([], alias="thunderforest.maps")
+ """List of enabled Thunderforest maps."""
+
+ thunderforest_access: LayerAccess = Field(LayerAccess.RESTRICTED,
+ alias="thunderforest.access")
+ """Thunderforest access restriction."""
+
+ disable_tile_proxy: bool = Field(False, alias="fietsboek.tile_proxy.disable")
+ """Disable the tile proxy."""
+
+ tile_layers: typing.List[TileLayerConfig] = []
+ """Tile layers."""
+
+ @validator("session_key")
+ def good_session_key(cls, value):
+ """Ensures that the session key has been changed from its default
+ value.
+ """
+ if value == "<EDIT THIS>":
+ raise ValueError("You need to edit the default session key!")
+
+ @validator("email_smtp_url")
+ def known_smtp_url(cls, value):
+ """Ensures that the SMTP URL is valid."""
+ parsed = urllib.parse.urlparse(value)
+ if parsed.scheme not in {'debug', 'smtp', 'smtp+ssl', 'smtp+starttls'}:
+ raise ValueError(f"Unknown mailing scheme {parsed.scheme}".strip())
+
+
+def parse(config):
+ """Parses the configuration into a :class:`Config`.
+
+ :param config: The configuration dict to parse.
+ :type config: dict
+ :return: The parsed (and validated) configuration.
+ :rtype: Config
+ :raises ValidationError: When the configuration is malformed.
+ """
+ config = config.copy()
+ keys = set(config.keys())
+ errors = []
+ # First, we try to extract the tile layers.
+ tile_layers = []
+ for key in config.keys():
+ match = re.match("^fietsboek\\.tile_layer\\.([A-Za-z0-9_-]+)$", key)
+ if not match:
+ continue
+ provider_id = match.group(1)
+ name = config[key]
+
+ prefix = f'{key}.'
+ inner = {k[len(prefix):]: v for (k, v) in config.items() if k.startswith(prefix)}
+ inner['layer_id'] = provider_id
+ inner['name'] = name
+ try:
+ layer_config = TileLayerConfig.parse_obj(inner)
+ tile_layers.append(layer_config)
+ except pydantic.ValidationError as validation_error:
+ errors.append((f'tile layer {provider_id}', validation_error))
+
+ keys.discard(key)
+ for field in TileLayerConfig.__fields__.values():
+ keys.discard(f'{prefix}{_field_name(field)}')
+
+ config['tile_layers'] = tile_layers
+
+ # Now we can parse the main config
+ try:
+ config = Config.parse_obj(config)
+ except pydantic.ValidationError as validation_error:
+ errors.append(('configuration', validation_error))
+
+ if errors:
+ raise ValidationError(errors)
+
+ for field in Config.__fields__.values():
+ keys.discard(_field_name(field))
+ keys -= KNOWN_PYRAMID_SETTINGS
+
+ for key in keys:
+ LOGGER.warning("Unknown configuration key: %r", key)
+
+ return config
+
+
+def _field_name(field):
+ alias = getattr(field, 'alias', None)
+ if alias:
+ return alias
+ return field.name
diff --git a/fietsboek/views/tileproxy.py b/fietsboek/views/tileproxy.py
index b9a32c4..631982e 100644
--- a/fietsboek/views/tileproxy.py
+++ b/fietsboek/views/tileproxy.py
@@ -10,7 +10,6 @@ import datetime
import random
import logging
import re
-from enum import Enum
from typing import NamedTuple
from itertools import chain
@@ -22,22 +21,7 @@ import requests
from requests.exceptions import ReadTimeout
from .. import __VERSION__
-
-
-class LayerType(Enum):
- """Enum to distinguish base layers and overlay layers."""
- BASE = "base"
- OVERLAY = "overlay"
-
-
-class LayerAccess(Enum):
- """Enum discerning whether a layer is publicly accessible or restriced to
- logged-in users.
-
- Note that in the future, a finer-grained distinction might be possible.
- """
- PUBLIC = "public"
- RESTRICTED = "restricted"
+from ..config import LayerType, LayerAccess
class TileSource(NamedTuple):
diff --git a/poetry.lock b/poetry.lock
index 8649847..a5e6951 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -414,6 +414,21 @@ optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
[[package]]
+name = "pydantic"
+version = "1.10.2"
+description = "Data validation and settings management using python type hints"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.dependencies]
+typing-extensions = ">=4.1.0"
+
+[package.extras]
+dotenv = ["python-dotenv (>=0.10.4)"]
+email = ["email-validator (>=1.0.3)"]
+
+[[package]]
name = "pygments"
version = "2.13.0"
description = "Pygments is a syntax highlighting package written in Python."
@@ -808,6 +823,17 @@ pymysql = ["pymysql", "pymysql (<1)"]
sqlcipher = ["sqlcipher3_binary"]
[[package]]
+name = "termcolor"
+version = "2.1.1"
+description = "ANSI color formatting for output in terminal"
+category = "main"
+optional = false
+python-versions = ">=3.7"
+
+[package.extras]
+tests = ["pytest", "pytest-cov"]
+
+[[package]]
name = "tomli"
version = "2.0.1"
description = "A lil' TOML parser"
@@ -990,7 +1016,7 @@ testing = ["WebTest", "pytest", "pytest-cov"]
[metadata]
lock-version = "1.1"
python-versions = "^3.7"
-content-hash = "006c651bb8c5717a8ac381f9ddc14a8a634ace15930646ade9c3d819c63f64d1"
+content-hash = "08ae6927db708c957185f78e6276fd5438ee36a0a30231e38bf89840c9a4778d"
[metadata.files]
alabaster = [
@@ -1358,6 +1384,44 @@ pycparser = [
{file = "pycparser-2.21-py2.py3-none-any.whl", hash = "sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9"},
{file = "pycparser-2.21.tar.gz", hash = "sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206"},
]
+pydantic = [
+ {file = "pydantic-1.10.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bb6ad4489af1bac6955d38ebcb95079a836af31e4c4f74aba1ca05bb9f6027bd"},
+ {file = "pydantic-1.10.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a1f5a63a6dfe19d719b1b6e6106561869d2efaca6167f84f5ab9347887d78b98"},
+ {file = "pydantic-1.10.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:352aedb1d71b8b0736c6d56ad2bd34c6982720644b0624462059ab29bd6e5912"},
+ {file = "pydantic-1.10.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:19b3b9ccf97af2b7519c42032441a891a5e05c68368f40865a90eb88833c2559"},
+ {file = "pydantic-1.10.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e9069e1b01525a96e6ff49e25876d90d5a563bc31c658289a8772ae186552236"},
+ {file = "pydantic-1.10.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:355639d9afc76bcb9b0c3000ddcd08472ae75318a6eb67a15866b87e2efa168c"},
+ {file = "pydantic-1.10.2-cp310-cp310-win_amd64.whl", hash = "sha256:ae544c47bec47a86bc7d350f965d8b15540e27e5aa4f55170ac6a75e5f73b644"},
+ {file = "pydantic-1.10.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4c805731c33a8db4b6ace45ce440c4ef5336e712508b4d9e1aafa617dc9907f"},
+ {file = "pydantic-1.10.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d49f3db871575e0426b12e2f32fdb25e579dea16486a26e5a0474af87cb1ab0a"},
+ {file = "pydantic-1.10.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:37c90345ec7dd2f1bcef82ce49b6235b40f282b94d3eec47e801baf864d15525"},
+ {file = "pydantic-1.10.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7b5ba54d026c2bd2cb769d3468885f23f43710f651688e91f5fb1edcf0ee9283"},
+ {file = "pydantic-1.10.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:05e00dbebbe810b33c7a7362f231893183bcc4251f3f2ff991c31d5c08240c42"},
+ {file = "pydantic-1.10.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:2d0567e60eb01bccda3a4df01df677adf6b437958d35c12a3ac3e0f078b0ee52"},
+ {file = "pydantic-1.10.2-cp311-cp311-win_amd64.whl", hash = "sha256:c6f981882aea41e021f72779ce2a4e87267458cc4d39ea990729e21ef18f0f8c"},
+ {file = "pydantic-1.10.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c4aac8e7103bf598373208f6299fa9a5cfd1fc571f2d40bf1dd1955a63d6eeb5"},
+ {file = "pydantic-1.10.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81a7b66c3f499108b448f3f004801fcd7d7165fb4200acb03f1c2402da73ce4c"},
+ {file = "pydantic-1.10.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bedf309630209e78582ffacda64a21f96f3ed2e51fbf3962d4d488e503420254"},
+ {file = "pydantic-1.10.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9300fcbebf85f6339a02c6994b2eb3ff1b9c8c14f502058b5bf349d42447dcf5"},
+ {file = "pydantic-1.10.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:216f3bcbf19c726b1cc22b099dd409aa371f55c08800bcea4c44c8f74b73478d"},
+ {file = "pydantic-1.10.2-cp37-cp37m-win_amd64.whl", hash = "sha256:dd3f9a40c16daf323cf913593083698caee97df2804aa36c4b3175d5ac1b92a2"},
+ {file = "pydantic-1.10.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b97890e56a694486f772d36efd2ba31612739bc6f3caeee50e9e7e3ebd2fdd13"},
+ {file = "pydantic-1.10.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9cabf4a7f05a776e7793e72793cd92cc865ea0e83a819f9ae4ecccb1b8aa6116"},
+ {file = "pydantic-1.10.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06094d18dd5e6f2bbf93efa54991c3240964bb663b87729ac340eb5014310624"},
+ {file = "pydantic-1.10.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cc78cc83110d2f275ec1970e7a831f4e371ee92405332ebfe9860a715f8336e1"},
+ {file = "pydantic-1.10.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:1ee433e274268a4b0c8fde7ad9d58ecba12b069a033ecc4645bb6303c062d2e9"},
+ {file = "pydantic-1.10.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:7c2abc4393dea97a4ccbb4ec7d8658d4e22c4765b7b9b9445588f16c71ad9965"},
+ {file = "pydantic-1.10.2-cp38-cp38-win_amd64.whl", hash = "sha256:0b959f4d8211fc964772b595ebb25f7652da3f22322c007b6fed26846a40685e"},
+ {file = "pydantic-1.10.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:c33602f93bfb67779f9c507e4d69451664524389546bacfe1bee13cae6dc7488"},
+ {file = "pydantic-1.10.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5760e164b807a48a8f25f8aa1a6d857e6ce62e7ec83ea5d5c5a802eac81bad41"},
+ {file = "pydantic-1.10.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6eb843dcc411b6a2237a694f5e1d649fc66c6064d02b204a7e9d194dff81eb4b"},
+ {file = "pydantic-1.10.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4b8795290deaae348c4eba0cebb196e1c6b98bdbe7f50b2d0d9a4a99716342fe"},
+ {file = "pydantic-1.10.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:e0bedafe4bc165ad0a56ac0bd7695df25c50f76961da29c050712596cf092d6d"},
+ {file = "pydantic-1.10.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2e05aed07fa02231dbf03d0adb1be1d79cabb09025dd45aa094aa8b4e7b9dcda"},
+ {file = "pydantic-1.10.2-cp39-cp39-win_amd64.whl", hash = "sha256:c1ba1afb396148bbc70e9eaa8c06c1716fdddabaf86e7027c5988bae2a829ab6"},
+ {file = "pydantic-1.10.2-py3-none-any.whl", hash = "sha256:1b6ee725bd6e83ec78b1aa32c5b1fa67a3a65badddde3976bca5fe4568f27709"},
+ {file = "pydantic-1.10.2.tar.gz", hash = "sha256:91b8e218852ef6007c2b98cd861601c6a09f1aa32bbbb74fab5b1c33d4a1e410"},
+]
pygments = [
{file = "Pygments-2.13.0-py3-none-any.whl", hash = "sha256:f643f331ab57ba3c9d89212ee4a2dabc6e94f117cf4eefde99a0574720d14c42"},
{file = "Pygments-2.13.0.tar.gz", hash = "sha256:56a8508ae95f98e2b9bdf93a6be5ae3f7d8af858b43e02c5a2ff083726be40c1"},
@@ -1501,6 +1565,10 @@ sqlalchemy = [
{file = "SQLAlchemy-1.4.44-cp39-cp39-win_amd64.whl", hash = "sha256:d3b6d4588994da73567bb00af9d7224a16c8027865a8aab53ae9be83f9b7cbd1"},
{file = "SQLAlchemy-1.4.44.tar.gz", hash = "sha256:2dda5f96719ae89b3ec0f1b79698d86eb9aecb1d54e990abb3fdd92c04b46a90"},
]
+termcolor = [
+ {file = "termcolor-2.1.1-py3-none-any.whl", hash = "sha256:fa852e957f97252205e105dd55bbc23b419a70fec0085708fc0515e399f304fd"},
+ {file = "termcolor-2.1.1.tar.gz", hash = "sha256:67cee2009adc6449c650f6bcf3bdeed00c8ba53a8cda5362733c53e0a39fb70b"},
+]
tomli = [
{file = "tomli-2.0.1-py3-none-any.whl", hash = "sha256:939de3e7a6161af0c887ef91b7d41a53e7c5a1ca976325f429cb46ea9bc30ecc"},
{file = "tomli-2.0.1.tar.gz", hash = "sha256:de526c12914f0c550d15924c62d72abc48d6fe7364aa87328337a31007fe8a4f"},
diff --git a/pyproject.toml b/pyproject.toml
index 1f87521..42f8ea3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -54,6 +54,8 @@ requests = "^2.28.1"
WebTest = {version = "^3", optional = true}
pytest = {version = "^7.2", optional = true}
pytest-cov = {version = "*", optional = true}
+pydantic = "^1.10.2"
+termcolor = "^2.1.1"
[tool.poetry.group.docs]
optional = true
diff --git a/testing.ini b/testing.ini
index 9fad722..62c1e26 100644
--- a/testing.ini
+++ b/testing.ini
@@ -13,6 +13,12 @@ pyramid.debug_routematch = false
pyramid.default_locale_name = en
sqlalchemy.url = sqlite:///%(here)s/testing.sqlite
+redis.url = redis://localhost
+
+fietsboek.default_tile_layers =
+
+email.from = Test <test@localhost>
+email.smtp_url = debug://
session_key = TESTING_KEY_DO_NOT_USE