diff options
-rw-r--r-- | CHANGELOG.rst | 7 | ||||
-rw-r--r-- | fietsboek/__init__.py | 51 | ||||
-rw-r--r-- | fietsboek/config.py | 260 | ||||
-rw-r--r-- | fietsboek/email.py | 50 | ||||
-rw-r--r-- | fietsboek/jinja2.py | 2 | ||||
-rw-r--r-- | fietsboek/templates/layout.jinja2 | 2 | ||||
-rw-r--r-- | fietsboek/views/account.py | 13 | ||||
-rw-r--r-- | fietsboek/views/default.py | 9 | ||||
-rw-r--r-- | fietsboek/views/tileproxy.py | 72 | ||||
-rw-r--r-- | poetry.lock | 70 | ||||
-rw-r--r-- | pyproject.toml | 2 | ||||
-rw-r--r-- | testing.ini | 6 |
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 |