aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.rst7
-rw-r--r--fietsboek/__init__.py51
-rw-r--r--fietsboek/config.py260
-rw-r--r--fietsboek/email.py50
-rw-r--r--fietsboek/jinja2.py2
-rw-r--r--fietsboek/templates/layout.jinja22
-rw-r--r--fietsboek/views/account.py13
-rw-r--r--fietsboek/views/default.py9
-rw-r--r--fietsboek/views/tileproxy.py72
-rw-r--r--poetry.lock70
-rw-r--r--pyproject.toml2
-rw-r--r--testing.ini6
12 files changed, 428 insertions, 116 deletions
diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index faa65e3..e975b64 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -4,11 +4,18 @@ Changelog
Unreleased
----------
+Changed
+^^^^^^^
+
+- The configuration file is now parsed and validated at application startup
+ with better error reports.
+
Fixed
^^^^^
- Account registration giving a 400 error.
+
0.4.0 - 2022-11-28
------------------
diff --git a/fietsboek/__init__.py b/fietsboek/__init__.py
index d9077d5..e208a61 100644
--- a/fietsboek/__init__.py
+++ b/fietsboek/__init__.py
@@ -9,13 +9,12 @@ import redis
from pyramid.config import Configurator
from pyramid.session import SignedCookieSessionFactory
from pyramid.csrf import CookieCSRFStoragePolicy
-from pyramid.settings import asbool, aslist
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')
@@ -40,7 +39,7 @@ def locale_negotiator(request):
if locale:
return locale
- installed_locales = request.registry.settings['available_locales']
+ installed_locales = request.config.available_locales
sentinel = object()
negotiated = request.accept_language.lookup(installed_locales, default=sentinel)
if negotiated is sentinel:
@@ -48,49 +47,30 @@ def locale_negotiator(request):
return negotiated
-def main(global_config, **settings):
+def main(_global_config, **settings):
""" This function returns a Pyramid WSGI application.
"""
- # pylint: disable=unused-argument, import-outside-toplevel, cyclic-import
- from .views import tileproxy
- if settings.get('session_key', '<EDIT THIS>') == '<EDIT THIS>':
- raise ValueError("Please set a session signing key (session_key) in your settings!")
-
- if 'fietsboek.data_dir' not in settings:
- raise ValueError("Please set a data directory (fietsboek.data_dir) in your settings!")
+ parsed_config = mod_config.parse(settings)
def data_manager(request):
- data_dir = request.registry.settings["fietsboek.data_dir"]
- return DataManager(Path(data_dir))
+ return DataManager(Path(request.config.data_dir))
def redis_(request):
- return redis.from_url(request.registry.settings["redis.url"])
-
- settings['enable_account_registration'] = asbool(
- settings.get('enable_account_registration', 'false'))
- settings['available_locales'] = aslist(
- settings.get('available_locales', 'en'))
- settings['fietsboek.pages'] = aslist(
- settings.get('fietsboek.pages', ''))
- settings['fietsboek.tile_proxy.disable'] = asbool(
- settings.get('fietsboek.tile_proxy.disable', 'false'))
- settings['thunderforest.maps'] = aslist(
- settings.get('thunderforest.maps', ''))
- settings['fietsboek.default_tile_layers'] = aslist(
- settings.get('fietsboek.default_tile_layers',
- 'osm satellite osmde opentopo topplusopen opensea cycling hiking'))
- settings['fietsboek.tile_layers'] = tileproxy.extract_tile_layers(settings)
+ return redis.from_url(request.config.redis_url)
+
+ def config_(_request):
+ return parsed_config
# Load the pages
page_manager = Pages()
- for path in settings['fietsboek.pages']:
+ for path in parsed_config.pages:
path = Path(path)
if path.is_dir():
page_manager.load_directory(path)
elif path.is_file():
page_manager.load_file(path)
- def pages(request):
+ def pages(_request):
return page_manager
my_session_factory = SignedCookieSessionFactory(settings['session_key'])
@@ -108,11 +88,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..e90deda
--- /dev/null
+++ b/fietsboek/config.py
@@ -0,0 +1,260 @@
+"""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, value in config.items():
+ match = re.match("^fietsboek\\.tile_layer\\.([A-Za-z0-9_-]+)$", key)
+ if not match:
+ continue
+ provider_id = match.group(1)
+
+ prefix = f'{value}.'
+ inner = {k[len(prefix):]: v for (k, v) in config.items() if k.startswith(prefix)}
+ inner['layer_id'] = provider_id
+ inner['name'] = value
+ 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/email.py b/fietsboek/email.py
index c07c97d..497debe 100644
--- a/fietsboek/email.py
+++ b/fietsboek/email.py
@@ -1,5 +1,6 @@
"""Utility functions for email sending."""
import logging
+import re
import smtplib
import sys
@@ -9,15 +10,14 @@ from email.message import EmailMessage
LOGGER = logging.getLogger(__name__)
-def prepare_message(settings, addr_to, subject):
+def prepare_message(sender, addr_to, subject):
"""Prepares an email message with the right headers.
The body of the message can be set by using
:meth:`~email.message.EmailMessage.set_content` on the returned message.
- :param settings: The application settings, used to access the email
- specific settings.
- :type settings: dict
+ :param sender: The email sender.
+ :type sender: str
:param addr_to: Address of the recipient.
:type addr_to: str
:param subject: Subject of the message.
@@ -25,44 +25,44 @@ def prepare_message(settings, addr_to, subject):
:return: A prepared message.
:rtype: email.message.EmailMessage
"""
- from_address = settings.get('email.from')
- if not from_address:
- LOGGER.warning("`email.from` is not set, make sure to check your configuration!")
message = EmailMessage()
message['To'] = addr_to
- message['From'] = f'Fietsboek <{from_address}>'
+ if '<' not in sender and '>' not in sender:
+ message['From'] = f'Fietsboek <{sender}>'
+ else:
+ message['From'] = sender
message['Subject'] = subject
return message
-def send_message(settings, message):
+def send_message(server_url, username, password, message):
"""Sends an email message using the STMP server configured in the settings.
The recipient is taken from the 'To'-header of the message.
- :parm settings: The application settings.
- :type settings: dict
+ :param server_url: The URL of the server for mail sending.
+ :type server_url: str
+ :param username: The username to authenticate, can be ``None`` or empty.
+ :type username str:
+ :param password: The password to authenticate, can be ``None`` or empty.
+ :type password: str
:param message: The message to send.
:type message: email.message.EmailMessage
"""
- smtp_server = settings.get('email.smtp_url')
- if not smtp_server:
- LOGGER.warning("`email.smtp_url` not set, no email can be sent!")
- return
- smtp_url = urlparse(smtp_server)
- if smtp_url.scheme == 'debug':
+ parsed_url = urlparse(server_url)
+ if parsed_url.scheme == 'debug':
print(message, file=sys.stderr)
return
try:
- if smtp_url.scheme == 'smtp':
- client = smtplib.SMTP(smtp_url.hostname, smtp_url.port)
- elif smtp_url.scheme == 'smtp+ssl':
- client = smtplib.SMTP_SSL(smtp_url.hostname, smtp_url.port)
- elif smtp_url.scheme == 'smtp+starttls':
- client = smtplib.SMTP(smtp_url.hostname, smtp_url.port)
+ if parsed_url.scheme == 'smtp':
+ client = smtplib.SMTP(parsed_url.hostname, parsed_url.port)
+ elif parsed_url.scheme == 'smtp+ssl':
+ client = smtplib.SMTP_SSL(parsed_url.hostname, parsed_url.port)
+ elif parsed_url.scheme == 'smtp+starttls':
+ client = smtplib.SMTP(parsed_url.hostname, parsed_url.port)
client.starttls()
- if 'email.smtp_user' in settings and 'email.smtp_password' in settings:
- client.login(settings['email.smtp_user'], settings['email.smtp_password'])
+ if username and password:
+ client.login(username, password)
client.send_message(message)
client.quit()
except smtplib.SMTPException:
diff --git a/fietsboek/jinja2.py b/fietsboek/jinja2.py
index a0b9457..e7ef522 100644
--- a/fietsboek/jinja2.py
+++ b/fietsboek/jinja2.py
@@ -95,7 +95,7 @@ def global_embed_tile_layers(request):
from .views import tileproxy
tile_sources = tileproxy.sources_for(request)
- if request.registry.settings.get("fietsboek.tile_proxy.disable"):
+ if request.config.disable_tile_proxy:
def _url(source):
return source.url_template
else:
diff --git a/fietsboek/templates/layout.jinja2 b/fietsboek/templates/layout.jinja2
index 9236b36..80981d1 100644
--- a/fietsboek/templates/layout.jinja2
+++ b/fietsboek/templates/layout.jinja2
@@ -81,7 +81,7 @@ const Bestaetigung = false;
<li>
<a class="dropdown-item" href="{{ request.route_url('login') }}">{{ _("page.navbar.login") }}</a>
</li>
- {% if request.registry.settings.get('enable_account_registration') %}
+ {% if request.config.enable_account_registration %}
<li>
<a class="dropdown-item" href="{{ request.route_url('create-account') }}">{{ _("page.navbar.create_account") }}</a>
</li>
diff --git a/fietsboek/views/account.py b/fietsboek/views/account.py
index 2816325..f9f48e9 100644
--- a/fietsboek/views/account.py
+++ b/fietsboek/views/account.py
@@ -18,7 +18,7 @@ def create_account(request):
:rtype: pyramid.response.Response
"""
# pylint: disable=unused-argument
- if not request.registry.settings['enable_account_registration']:
+ if not request.config.enable_account_registration:
return HTTPForbidden()
return {}
@@ -34,7 +34,7 @@ def do_create_account(request):
:rtype: pyramid.response.Response
"""
# pylint: disable=duplicate-code
- if not request.registry.settings['enable_account_registration']:
+ if not request.config.enable_account_registration:
return HTTPForbidden()
password = request.params["password"]
try:
@@ -61,13 +61,18 @@ def do_create_account(request):
request.dbsession.add(token)
message = email.prepare_message(
- request.registry.settings,
+ request.config.email_from,
user.email,
request.localizer.translate(_('email.verify_mail.subject')),
)
message.set_content(request.localizer.translate(_('email.verify.text'))
.format(request.route_url('use-token', uuid=token.uuid)))
- email.send_message(request.registry.settings, message)
+ email.send_message(
+ request.config.email_smtp_url,
+ request.config.email_username,
+ request.config.email_password.get_secret_value(),
+ message,
+ )
request.session.flash(request.localizer.translate(_("flash.a_confirmation_link_has_been_sent")))
return HTTPFound(request.route_url('login'))
diff --git a/fietsboek/views/default.py b/fietsboek/views/default.py
index a36e4c3..f4aaa8f 100644
--- a/fietsboek/views/default.py
+++ b/fietsboek/views/default.py
@@ -166,7 +166,7 @@ def do_password_reset(request):
request.session.flash(request.localizer.translate(_("flash.password_token_generated")))
mail = email.prepare_message(
- request.registry.settings,
+ request.config.email_from,
user.email,
request.localizer.translate(_("page.password_reset.email.subject")),
)
@@ -175,7 +175,12 @@ def do_password_reset(request):
.translate(_("page.password_reset.email.body"))
.format(request.route_url('use-token', uuid=token.uuid))
)
- email.send_message(request.registry.settings, mail)
+ email.send_message(
+ request.config.email_smtp_url,
+ request.config.email_username,
+ request.config.email_password.get_secret_value(),
+ mail,
+ )
return HTTPFound(request.route_url('password-reset'))
diff --git a/fietsboek/views/tileproxy.py b/fietsboek/views/tileproxy.py
index b9a32c4..3e2abc1 100644
--- a/fietsboek/views/tileproxy.py
+++ b/fietsboek/views/tileproxy.py
@@ -9,8 +9,6 @@ Additionally, this protects the users' IP, as only fietsboek can see it.
import datetime
import random
import logging
-import re
-from enum import Enum
from typing import NamedTuple
from itertools import chain
@@ -22,22 +20,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):
@@ -193,7 +176,7 @@ def tile_proxy(request):
:return: The HTTP response.
:rtype: pyramid.response.Response
"""
- if request.registry.settings.get("fietsboek.tile_proxy.disable"):
+ if request.config.disable_tile_proxy:
raise HTTPBadRequest("Tile proxying is disabled")
provider = request.matchdict['provider']
@@ -221,7 +204,7 @@ def tile_proxy(request):
headers = {
"user-agent": f"Fietsboek-Tile-Proxy/{__VERSION__}",
}
- from_mail = request.registry.settings.get('email.from')
+ from_mail = request.config.email_from
if from_mail:
headers["from"] = from_mail
@@ -251,42 +234,41 @@ def sources_for(request):
:return: A list of tile sources.
:rtype: list[TileSource]
"""
- settings = request.registry.settings
return [
source for source in chain(
(default_layer for default_layer in DEFAULT_TILE_LAYERS
- if default_layer.key in settings["fietsboek.default_tile_layers"]),
- settings["fietsboek.tile_layers"]
+ if default_layer.key in request.config.default_tile_layers),
+ extract_tile_layers(request.config),
)
if source.access == LayerAccess.PUBLIC or request.identity is not None
]
-def extract_tile_layers(settings):
+def extract_tile_layers(config):
"""Extract all defined tile layers from the settings.
- :param settings: The application settings.
- :type settings: dict
+ :param config: The fietsboek config.
+ :type config: fietsboek.config.Config
:return: A list of extracted tile sources.
:rtype: list[TileSource]
"""
layers = []
- layers.extend(_extract_thunderforest(settings))
- layers.extend(_extract_user_layers(settings))
+ layers.extend(_extract_thunderforest(config))
+ layers.extend(_extract_user_layers(config))
return layers
-def _extract_thunderforest(settings):
+def _extract_thunderforest(config):
# Thunderforest Shortcut!
- tf_api_key = settings.get("thunderforest.api_key")
+ tf_api_key = config.thunderforest_key.get_secret_value()
if tf_api_key:
- tf_access = LayerAccess(settings.get("thunderforest.access", "restricted"))
+ tf_access = config.thunderforest_access
tf_attribution = ' | '.join([
_jb_copy,
_href("https://www.thunderforest.com/", "Thunderforest"),
_href("https://www.openstreetmap.org/", "OpenStreetMap"),
])
- for tf_map in settings["thunderforest.maps"]:
+ for tf_map in config.thunderforest_maps:
url = (f"https://tile.thunderforest.com/{tf_map}/"
f"{{z}}/{{x}}/{{y}}.png?apikey={tf_api_key}")
yield TileSource(
@@ -295,19 +277,15 @@ def _extract_thunderforest(settings):
)
-def _extract_user_layers(settings):
+def _extract_user_layers(config):
# Any other custom maps
- for key in settings.keys():
- match = re.match("^fietsboek\\.tile_layer\\.([A-Za-z0-9_-]+)$", key)
- if not match:
- continue
-
- provider_id = match.group(1)
- name = settings[key]
- url = settings[f"{key}.url"]
- layer_type = LayerType(settings.get(f"{key}.type", "base"))
- zoom = int(settings.get(f"{key}.zoom", 22))
- attribution = settings.get(f"{key}.attribution", _jb_copy)
- access = LayerAccess(settings.get(f"{key}.access", "public"))
-
- yield TileSource(provider_id, name, url, layer_type, zoom, access, attribution)
+ for layer in config.tile_layers:
+ yield TileSource(
+ layer.layer_id,
+ layer.name,
+ layer.url,
+ layer.layer_type,
+ layer.zoom,
+ layer.access,
+ layer.attribution
+ )
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