From 9a7079d60f0a6d3136216c4cde70af827e3e782b Mon Sep 17 00:00:00 2001 From: liiight Date: Sat, 22 Feb 2020 17:07:26 +0200 Subject: [PATCH 001/137] changed the core to use pydantic instead of json schema --- notifiers/core.py | 320 +-------------------------- notifiers/models/__init__.py | 0 notifiers/models/provider.py | 178 +++++++++++++++ notifiers/models/response.py | 57 +++++ notifiers/providers/email.py | 4 +- notifiers/providers/gitter.py | 6 +- notifiers/providers/hipchat.py | 6 +- notifiers/providers/join.py | 6 +- notifiers/providers/mailgun.py | 4 +- notifiers/providers/pagerduty.py | 4 +- notifiers/providers/popcornnotify.py | 4 +- notifiers/providers/pushbullet.py | 6 +- notifiers/providers/pushover.py | 6 +- notifiers/providers/simplepush.py | 4 +- notifiers/providers/slack.py | 4 +- notifiers/providers/statuspage.py | 6 +- notifiers/providers/telegram.py | 6 +- notifiers/providers/twilio.py | 4 +- notifiers/providers/zulip.py | 4 +- tests/conftest.py | 6 +- tests/test_core.py | 4 +- 21 files changed, 279 insertions(+), 360 deletions(-) create mode 100644 notifiers/models/__init__.py create mode 100644 notifiers/models/provider.py create mode 100644 notifiers/models/response.py diff --git a/notifiers/core.py b/notifiers/core.py index 5ed5f7e0..20df3b0c 100644 --- a/notifiers/core.py +++ b/notifiers/core.py @@ -1,18 +1,8 @@ import logging -from abc import ABC -from abc import abstractmethod -import jsonschema -import requests -from jsonschema.exceptions import best_match - -from .exceptions import BadArguments from .exceptions import NoSuchNotifierError -from .exceptions import NotificationError -from .exceptions import SchemaError -from .utils.helpers import dict_from_environs -from .utils.helpers import merge_dicts -from .utils.schema.formats import format_checker +from .models.provider import Provider +from .models.response import Response DEFAULT_ENVIRON_PREFIX = "NOTIFIERS_" @@ -21,312 +11,6 @@ FAILURE_STATUS = "Failure" SUCCESS_STATUS = "Success" - -class Response: - """ - A wrapper for the Notification response. - - :param status: Response status string. ``SUCCESS`` or ``FAILED`` - :param provider: Provider name that returned that response. Correlates to :attr:`~notifiers.core.Provider.name` - :param data: The notification data that was used for the notification - :param response: The response object that was returned. Usually :class:`requests.Response` - :param errors: Holds a list of errors if relevant - """ - - def __init__( - self, - status: str, - provider: str, - data: dict, - response: requests.Response = None, - errors: list = None, - ): - self.status = status - self.provider = provider - self.data = data - self.response = response - self.errors = errors - - def __repr__(self): - return f"" - - def raise_on_errors(self): - """ - Raises a :class:`~notifiers.exceptions.NotificationError` if response hold errors - - :raises: :class:`~notifiers.exceptions.NotificationError`: If response has errors - """ - if self.errors: - raise NotificationError( - provider=self.provider, - data=self.data, - errors=self.errors, - response=self.response, - ) - - @property - def ok(self): - return self.errors is None - - -class SchemaResource(ABC): - """Base class that represent an object schema and its utility methods""" - - @property - @abstractmethod - def _required(self) -> dict: - """Will hold the schema's required part""" - pass - - @property - @abstractmethod - def _schema(self) -> dict: - """Resource JSON schema without the required part""" - pass - - _merged_schema = None - - @property - @abstractmethod - def name(self) -> str: - """Resource provider name""" - pass - - @property - def schema(self) -> dict: - """ - A property method that'll return the constructed provider schema. - Schema MUST be an object and this method must be overridden - - :return: JSON schema of the provider - """ - if not self._merged_schema: - log.debug("merging required dict into schema for %s", self.name) - self._merged_schema = self._schema.copy() - self._merged_schema.update(self._required) - return self._merged_schema - - @property - def arguments(self) -> dict: - """Returns all of the provider argument as declared in the JSON schema""" - return dict(self.schema["properties"].items()) - - @property - def required(self) -> dict: - """Returns a dict of the relevant required parts of the schema""" - return self._required - - @property - def defaults(self) -> dict: - """A dict of default provider values if such is needed""" - return {} - - def create_response( - self, data: dict = None, response: requests.Response = None, errors: list = None - ) -> Response: - """ - Helper function to generate a :class:`~notifiers.core.Response` object - - :param data: The data that was used to send the notification - :param response: :class:`requests.Response` if exist - :param errors: List of errors if relevant - """ - status = FAILURE_STATUS if errors else SUCCESS_STATUS - return Response( - status=status, - provider=self.name, - data=data, - response=response, - errors=errors, - ) - - def _merge_defaults(self, data: dict) -> dict: - """ - Convenience method that calls :func:`~notifiers.utils.helpers.merge_dicts` in order to merge - default values - - :param data: Notification data - :return: A merged dict of provided data with added defaults - """ - log.debug("merging defaults %s into data %s", self.defaults, data) - return merge_dicts(data, self.defaults) - - def _get_environs(self, prefix: str = None) -> dict: - """ - Fetches set environment variables if such exist, via the :func:`~notifiers.utils.helpers.dict_from_environs` - Searches for `[PREFIX_NAME]_[PROVIDER_NAME]_[ARGUMENT]` for each of the arguments defined in the schema - - :param prefix: The environ prefix to use. If not supplied, uses the default - :return: A dict of arguments and value retrieved from environs - """ - if not prefix: - log.debug("using default environ prefix") - prefix = DEFAULT_ENVIRON_PREFIX - return dict_from_environs(prefix, self.name, list(self.arguments.keys())) - - def _prepare_data(self, data: dict) -> dict: - """ - Use this method to manipulate data that'll fit the respected provider API. - For example, all provider must use the ``message`` argument but sometimes provider expects a different - variable name for this, like ``text``. - - :param data: Notification data - :return: Returns manipulated data, if there's a need for such manipulations. - """ - return data - - def _validate_schema(self): - """ - Validates provider schema for syntax issues. Raises :class:`~notifiers.exceptions.SchemaError` if relevant - - :raises: :class:`~notifiers.exceptions.SchemaError` - """ - try: - log.debug("validating provider schema") - self.validator.check_schema(self.schema) - except jsonschema.SchemaError as e: - raise SchemaError( - schema_error=e.message, provider=self.name, data=self.schema - ) - - def _validate_data(self, data: dict): - """ - Validates data against provider schema. Raises :class:`~notifiers.exceptions.BadArguments` if relevant - - :param data: Data to validate - :raises: :class:`~notifiers.exceptions.BadArguments` - """ - log.debug("validating provided data") - e = best_match(self.validator.iter_errors(data)) - if e: - custom_error_key = f"error_{e.validator}" - msg = ( - e.schema[custom_error_key] - if e.schema.get(custom_error_key) - else e.message - ) - raise BadArguments(validation_error=msg, provider=self.name, data=data) - - def _validate_data_dependencies(self, data: dict) -> dict: - """ - Validates specific dependencies based on the content of the data, as opposed to its structure which can be - verified on the schema level - - :param data: Data to validate - :return: Return data if its valid - :raises: :class:`~notifiers.exceptions.NotifierException` - """ - return data - - def _process_data(self, **data) -> dict: - """ - The main method that process all resources data. Validates schema, gets environs, validates data, prepares - it via provider requirements, merges defaults and check for data dependencies - - :param data: The raw data passed by the notifiers client - :return: Processed data - """ - env_prefix = data.pop("env_prefix", None) - environs = self._get_environs(env_prefix) - if environs: - data = merge_dicts(data, environs) - - data = self._merge_defaults(data) - self._validate_data(data) - data = self._validate_data_dependencies(data) - data = self._prepare_data(data) - return data - - def __init__(self): - self.validator = jsonschema.Draft4Validator( - self.schema, format_checker=format_checker - ) - self._validate_schema() - - -class Provider(SchemaResource, ABC): - """The Base class all notification providers inherit from.""" - - _resources = {} - - def __repr__(self): - return f"" - - def __getattr__(self, item): - if item in self._resources: - return self._resources[item] - raise AttributeError(f"{self} does not have a property {item}") - - @property - @abstractmethod - def base_url(self): - pass - - @property - @abstractmethod - def site_url(self): - pass - - @property - def metadata(self) -> dict: - """ - Returns a dict of the provider metadata as declared. Override if needed. - """ - return {"base_url": self.base_url, "site_url": self.site_url, "name": self.name} - - @property - def resources(self) -> list: - """Return a list of names of relevant :class:`~notifiers.core.ProviderResource` objects""" - return list(self._resources.keys()) - - @abstractmethod - def _send_notification(self, data: dict) -> Response: - """ - The core method to trigger the provider notification. Must be overridden. - - :param data: Notification data - """ - pass - - def notify(self, raise_on_errors: bool = False, **kwargs) -> Response: - """ - The main method to send notifications. Prepares the data via the - :meth:`~notifiers.core.SchemaResource._prepare_data` method and then sends the notification - via the :meth:`~notifiers.core.Provider._send_notification` method - - :param kwargs: Notification data - :param raise_on_errors: Should the :meth:`~notifiers.core.Response.raise_on_errors` be invoked immediately - :return: A :class:`~notifiers.core.Response` object - :raises: :class:`~notifiers.exceptions.NotificationError` if ``raise_on_errors`` is set to True and response - contained errors - """ - data = self._process_data(**kwargs) - rsp = self._send_notification(data) - if raise_on_errors: - rsp.raise_on_errors() - return rsp - - -class ProviderResource(SchemaResource, ABC): - """The base class that is used to fetch provider related resources like rooms, channels, users etc.""" - - @property - @abstractmethod - def resource_name(self): - pass - - @abstractmethod - def _get_resource(self, data: dict): - pass - - def __call__(self, **kwargs): - data = self._process_data(**kwargs) - return self._get_resource(data) - - def __repr__(self): - return f"" - - # Avoid premature import from .providers import _all_providers # noqa: E402 diff --git a/notifiers/models/__init__.py b/notifiers/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/notifiers/models/provider.py b/notifiers/models/provider.py new file mode 100644 index 00000000..3e06e647 --- /dev/null +++ b/notifiers/models/provider.py @@ -0,0 +1,178 @@ +from abc import ABC +from abc import abstractmethod + +import requests +from pydantic import BaseModel + +from notifiers.core import DEFAULT_ENVIRON_PREFIX +from notifiers.core import log +from notifiers.models.response import Response +from notifiers.models.response import ResponseStatus +from notifiers.utils.helpers import dict_from_environs +from notifiers.utils.helpers import merge_dicts + + +class SchemaModel(BaseModel): + """The base class for Schemas""" + + +class SchemaResource(ABC): + """Base class that represent an object schema and its utility methods""" + + schema_model: SchemaModel + + @property + @abstractmethod + def name(self) -> str: + """Resource provider name""" + pass + + @property + def schema(self) -> dict: + return self.schema_model.schema() + + @property + def arguments(self) -> dict: + return self.schema["properties"] + + def create_response( + self, data: dict = None, response: requests.Response = None, errors: list = None + ) -> Response: + """ + Helper function to generate a :class:`~notifiers.core.Response` object + + :param data: The data that was used to send the notification + :param response: :class:`requests.Response` if exist + :param errors: List of errors if relevant + """ + status = ResponseStatus.FAILURE if errors else ResponseStatus.SUCCESS + return Response( + status=status, + provider=self.name, + data=data, + response=response, + errors=errors, + ) + + def _get_environs(self, prefix: str = None) -> dict: + """ + Fetches set environment variables if such exist, via the :func:`~notifiers.utils.helpers.dict_from_environs` + Searches for `[PREFIX_NAME]_[PROVIDER_NAME]_[ARGUMENT]` for each of the arguments defined in the schema + + :param prefix: The environ prefix to use. If not supplied, uses the default + :return: A dict of arguments and value retrieved from environs + """ + if not prefix: + log.debug("using default environ prefix") + prefix = DEFAULT_ENVIRON_PREFIX + return dict_from_environs(prefix, self.name, list(self.arguments.keys())) + + def _prepare_data(self, data: dict) -> dict: + """ + Use this method to manipulate data that'll fit the respected provider API. + For example, all provider must use the ``message`` argument but sometimes provider expects a different + variable name for this, like ``text``. + + :param data: Notification data + :return: Returns manipulated data, if there's a need for such manipulations. + """ + return data + + def _process_data(self, **data) -> dict: + """ + The main method that process all resources data. Validates schema, gets environs, validates data, prepares + it via provider requirements, merges defaults and check for data dependencies + + :param data: The raw data passed by the notifiers client + :return: Processed data + """ + env_prefix = data.pop("env_prefix", None) + environs = self._get_environs(env_prefix) + if environs: + data = merge_dicts(data, environs) + + data = self._prepare_data(data) + return data + + +class Provider(SchemaResource, ABC): + """The Base class all notification providers inherit from.""" + + _resources = {} + + def __repr__(self): + return f"" + + def __getattr__(self, item): + if item in self._resources: + return self._resources[item] + raise AttributeError(f"{self} does not have a property {item}") + + @property + @abstractmethod + def base_url(self): + pass + + @property + @abstractmethod + def site_url(self): + pass + + @property + def metadata(self) -> dict: + """ + Returns a dict of the provider metadata as declared. Override if needed. + """ + return {"base_url": self.base_url, "site_url": self.site_url, "name": self.name} + + @property + def resources(self) -> list: + """Return a list of names of relevant :class:`~notifiers.core.ProviderResource` objects""" + return list(self._resources.keys()) + + @abstractmethod + def _send_notification(self, data: dict) -> Response: + """ + The core method to trigger the provider notification. Must be overridden. + + :param data: Notification data + """ + pass + + def notify(self, raise_on_errors: bool = False, **kwargs) -> Response: + """ + The main method to send notifications. Prepares the data via the + :meth:`~notifiers.core.SchemaResource._prepare_data` method and then sends the notification + via the :meth:`~notifiers.core.Provider._send_notification` method + + :param kwargs: Notification data + :param raise_on_errors: Should the :meth:`~notifiers.core.Response.raise_on_errors` be invoked immediately + :return: A :class:`~notifiers.core.Response` object + :raises: :class:`~notifiers.exceptions.NotificationError` if ``raise_on_errors`` is set to True and response + contained errors + """ + data = self._process_data(**kwargs) + rsp = self._send_notification(data) + if raise_on_errors: + rsp.raise_on_errors() + return rsp + + +class ProviderResource(SchemaResource, ABC): + """The base class that is used to fetch provider related resources like rooms, channels, users etc.""" + + @property + @abstractmethod + def resource_name(self): + pass + + @abstractmethod + def _get_resource(self, data: dict): + pass + + def __call__(self, **kwargs): + data = self._process_data(**kwargs) + return self._get_resource(data) + + def __repr__(self): + return f"" diff --git a/notifiers/models/response.py b/notifiers/models/response.py new file mode 100644 index 00000000..1997c594 --- /dev/null +++ b/notifiers/models/response.py @@ -0,0 +1,57 @@ +from enum import Enum + +import requests + +from ..exceptions import NotificationError + + +class ResponseStatus(Enum): + SUCCESS = "success" + FAILURE = "failure" + + +class Response: + """ + A wrapper for the Notification response. + + :param status: Response status string. ``SUCCESS`` or ``FAILED`` + :param provider: Provider name that returned that response. Correlates to :attr:`~notifiers.core.Provider.name` + :param data: The notification data that was used for the notification + :param response: The response object that was returned. Usually :class:`requests.Response` + :param errors: Holds a list of errors if relevant + """ + + def __init__( + self, + status: ResponseStatus, + provider: str, + data: dict, + response: requests.Response = None, + errors: list = None, + ): + self.status = status + self.provider = provider + self.data = data + self.response = response + self.errors = errors + + def __repr__(self): + return f"" + + def raise_on_errors(self): + """ + Raises a :class:`~notifiers.exceptions.NotificationError` if response hold errors + + :raises: :class:`~notifiers.exceptions.NotificationError`: If response has errors + """ + if self.errors: + raise NotificationError( + provider=self.provider, + data=self.data, + errors=self.errors, + response=self.response, + ) + + @property + def ok(self): + return self.errors is None diff --git a/notifiers/providers/email.py b/notifiers/providers/email.py index 284775b6..99bceab0 100644 --- a/notifiers/providers/email.py +++ b/notifiers/providers/email.py @@ -11,8 +11,8 @@ from typing import List from typing import Tuple -from ..core import Provider -from ..core import Response +from ..models.provider import Provider +from ..models.response import Response from ..utils.schema.helpers import list_to_commas from ..utils.schema.helpers import one_or_more diff --git a/notifiers/providers/gitter.py b/notifiers/providers/gitter.py index ac93833a..c499f2a2 100644 --- a/notifiers/providers/gitter.py +++ b/notifiers/providers/gitter.py @@ -1,7 +1,7 @@ -from ..core import Provider -from ..core import ProviderResource -from ..core import Response from ..exceptions import ResourceError +from ..models.provider import Provider +from ..models.provider import ProviderResource +from ..models.response import Response from ..utils import requests diff --git a/notifiers/providers/hipchat.py b/notifiers/providers/hipchat.py index 1fa6b788..91444366 100644 --- a/notifiers/providers/hipchat.py +++ b/notifiers/providers/hipchat.py @@ -1,9 +1,9 @@ import copy -from ..core import Provider -from ..core import ProviderResource -from ..core import Response from ..exceptions import ResourceError +from ..models.provider import Provider +from ..models.provider import ProviderResource +from ..models.response import Response from ..utils import requests diff --git a/notifiers/providers/join.py b/notifiers/providers/join.py index 4c0ef3d8..6b6e76b6 100644 --- a/notifiers/providers/join.py +++ b/notifiers/providers/join.py @@ -2,10 +2,10 @@ import requests -from ..core import Provider -from ..core import ProviderResource -from ..core import Response from ..exceptions import ResourceError +from ..models.provider import Provider +from ..models.provider import ProviderResource +from ..models.response import Response from ..utils.schema.helpers import list_to_commas from ..utils.schema.helpers import one_or_more diff --git a/notifiers/providers/mailgun.py b/notifiers/providers/mailgun.py index 967fd495..70eed9d6 100644 --- a/notifiers/providers/mailgun.py +++ b/notifiers/providers/mailgun.py @@ -1,7 +1,7 @@ import json -from ..core import Provider -from ..core import Response +from ..models.provider import Provider +from ..models.response import Response from ..utils import requests from ..utils.schema.helpers import one_or_more diff --git a/notifiers/providers/pagerduty.py b/notifiers/providers/pagerduty.py index 27e434e0..e3732e41 100644 --- a/notifiers/providers/pagerduty.py +++ b/notifiers/providers/pagerduty.py @@ -1,5 +1,5 @@ -from ..core import Provider -from ..core import Response +from ..models.provider import Provider +from ..models.response import Response from ..utils import requests diff --git a/notifiers/providers/popcornnotify.py b/notifiers/providers/popcornnotify.py index 32d905f1..f392dd02 100644 --- a/notifiers/providers/popcornnotify.py +++ b/notifiers/providers/popcornnotify.py @@ -1,5 +1,5 @@ -from ..core import Provider -from ..core import Response +from ..models.provider import Provider +from ..models.response import Response from ..utils import requests from ..utils.schema.helpers import list_to_commas from ..utils.schema.helpers import one_or_more diff --git a/notifiers/providers/pushbullet.py b/notifiers/providers/pushbullet.py index 7698b330..3af385e6 100644 --- a/notifiers/providers/pushbullet.py +++ b/notifiers/providers/pushbullet.py @@ -1,7 +1,7 @@ -from ..core import Provider -from ..core import ProviderResource -from ..core import Response from ..exceptions import ResourceError +from ..models.provider import Provider +from ..models.provider import ProviderResource +from ..models.response import Response from ..utils import requests diff --git a/notifiers/providers/pushover.py b/notifiers/providers/pushover.py index 61416454..2ef9fdce 100644 --- a/notifiers/providers/pushover.py +++ b/notifiers/providers/pushover.py @@ -1,7 +1,7 @@ -from ..core import Provider -from ..core import ProviderResource -from ..core import Response from ..exceptions import ResourceError +from ..models.provider import Provider +from ..models.provider import ProviderResource +from ..models.response import Response from ..utils import requests from ..utils.schema.helpers import list_to_commas from ..utils.schema.helpers import one_or_more diff --git a/notifiers/providers/simplepush.py b/notifiers/providers/simplepush.py index dd5f60cb..98f2dc53 100644 --- a/notifiers/providers/simplepush.py +++ b/notifiers/providers/simplepush.py @@ -1,5 +1,5 @@ -from ..core import Provider -from ..core import Response +from ..models.provider import Provider +from ..models.response import Response from ..utils import requests diff --git a/notifiers/providers/slack.py b/notifiers/providers/slack.py index 0b1239d8..bfde037b 100644 --- a/notifiers/providers/slack.py +++ b/notifiers/providers/slack.py @@ -1,5 +1,5 @@ -from ..core import Provider -from ..core import Response +from ..models.provider import Provider +from ..models.response import Response from ..utils import requests diff --git a/notifiers/providers/statuspage.py b/notifiers/providers/statuspage.py index 04826fee..545d99ee 100644 --- a/notifiers/providers/statuspage.py +++ b/notifiers/providers/statuspage.py @@ -1,8 +1,8 @@ -from ..core import Provider -from ..core import ProviderResource -from ..core import Response from ..exceptions import BadArguments from ..exceptions import ResourceError +from ..models.provider import Provider +from ..models.provider import ProviderResource +from ..models.response import Response from ..utils import requests diff --git a/notifiers/providers/telegram.py b/notifiers/providers/telegram.py index e5d5c7c0..c85833ef 100644 --- a/notifiers/providers/telegram.py +++ b/notifiers/providers/telegram.py @@ -1,7 +1,7 @@ -from ..core import Provider -from ..core import ProviderResource -from ..core import Response from ..exceptions import ResourceError +from ..models.provider import Provider +from ..models.provider import ProviderResource +from ..models.response import Response from ..utils import requests diff --git a/notifiers/providers/twilio.py b/notifiers/providers/twilio.py index 1ec700ab..42a626d7 100644 --- a/notifiers/providers/twilio.py +++ b/notifiers/providers/twilio.py @@ -1,5 +1,5 @@ -from ..core import Provider -from ..core import Response +from ..models.provider import Provider +from ..models.response import Response from ..utils import requests from ..utils.helpers import snake_to_camel_case diff --git a/notifiers/providers/zulip.py b/notifiers/providers/zulip.py index c9a99065..5264eb0e 100644 --- a/notifiers/providers/zulip.py +++ b/notifiers/providers/zulip.py @@ -1,6 +1,6 @@ -from ..core import Provider -from ..core import Response from ..exceptions import NotifierException +from ..models.provider import Provider +from ..models.response import Response from ..utils import requests diff --git a/tests/conftest.py b/tests/conftest.py index 578e2a9f..f7e8539c 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -8,11 +8,11 @@ from click.testing import CliRunner from notifiers.core import get_notifier -from notifiers.core import Provider -from notifiers.core import ProviderResource -from notifiers.core import Response from notifiers.core import SUCCESS_STATUS from notifiers.logging import NotificationHandler +from notifiers.models.provider import Provider +from notifiers.models.provider import ProviderResource +from notifiers.models.response import Response from notifiers.providers import _all_providers from notifiers.utils.helpers import text_to_bool from notifiers.utils.schema.helpers import list_to_commas diff --git a/tests/test_core.py b/tests/test_core.py index 42872190..d1273276 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2,13 +2,13 @@ import notifiers from notifiers import notify -from notifiers.core import Provider -from notifiers.core import Response from notifiers.core import SUCCESS_STATUS from notifiers.exceptions import BadArguments from notifiers.exceptions import NoSuchNotifierError from notifiers.exceptions import NotificationError from notifiers.exceptions import SchemaError +from notifiers.models.provider import Provider +from notifiers.models.response import Response class TestCore: From 69aaea9d57aac9e15961ec99656d6bc84ab89ddd Mon Sep 17 00:00:00 2001 From: liiight Date: Sat, 22 Feb 2020 22:28:15 +0200 Subject: [PATCH 002/137] converted email to use the new schema model --- notifiers/models/provider.py | 29 ++++---- notifiers/providers/email.py | 139 +++++++++++++---------------------- 2 files changed, 68 insertions(+), 100 deletions(-) diff --git a/notifiers/models/provider.py b/notifiers/models/provider.py index 3e06e647..484bedfb 100644 --- a/notifiers/models/provider.py +++ b/notifiers/models/provider.py @@ -3,9 +3,11 @@ import requests from pydantic import BaseModel +from pydantic import ValidationError from notifiers.core import DEFAULT_ENVIRON_PREFIX from notifiers.core import log +from notifiers.exceptions import BadArguments from notifiers.models.response import Response from notifiers.models.response import ResponseStatus from notifiers.utils.helpers import dict_from_environs @@ -15,6 +17,12 @@ class SchemaModel(BaseModel): """The base class for Schemas""" + @staticmethod + def to_list(value): + if not isinstance(value, list): + return [value] + return value + class SchemaResource(ABC): """Base class that represent an object schema and its utility methods""" @@ -35,6 +43,13 @@ def schema(self) -> dict: def arguments(self) -> dict: return self.schema["properties"] + def validate_data(self, data: dict) -> SchemaModel: + try: + return self.schema_model.parse_obj(data) + except ValidationError: + # todo handle validation error and return custom + raise BadArguments + def create_response( self, data: dict = None, response: requests.Response = None, errors: list = None ) -> Response: @@ -67,17 +82,6 @@ def _get_environs(self, prefix: str = None) -> dict: prefix = DEFAULT_ENVIRON_PREFIX return dict_from_environs(prefix, self.name, list(self.arguments.keys())) - def _prepare_data(self, data: dict) -> dict: - """ - Use this method to manipulate data that'll fit the respected provider API. - For example, all provider must use the ``message`` argument but sometimes provider expects a different - variable name for this, like ``text``. - - :param data: Notification data - :return: Returns manipulated data, if there's a need for such manipulations. - """ - return data - def _process_data(self, **data) -> dict: """ The main method that process all resources data. Validates schema, gets environs, validates data, prepares @@ -91,7 +95,7 @@ def _process_data(self, **data) -> dict: if environs: data = merge_dicts(data, environs) - data = self._prepare_data(data) + data = self.validate_data(data).dict() return data @@ -137,7 +141,6 @@ def _send_notification(self, data: dict) -> Response: :param data: Notification data """ - pass def notify(self, raise_on_errors: bool = False, **kwargs) -> Response: """ diff --git a/notifiers/providers/email.py b/notifiers/providers/email.py index 99bceab0..6810366a 100644 --- a/notifiers/providers/email.py +++ b/notifiers/providers/email.py @@ -10,15 +10,60 @@ from smtplib import SMTPServerDisconnected from typing import List from typing import Tuple +from typing import Union + +from pydantic import AnyUrl +from pydantic import EmailStr +from pydantic import Field +from pydantic import FilePath +from pydantic import root_validator +from pydantic import StrictInt +from pydantic import validator from ..models.provider import Provider +from ..models.provider import SchemaModel from ..models.response import Response -from ..utils.schema.helpers import list_to_commas -from ..utils.schema.helpers import one_or_more -DEFAULT_SUBJECT = "New email from 'notifiers'!" -DEFAULT_FROM = f"{getpass.getuser()}@{socket.getfqdn()}" -DEFAULT_SMTP_HOST = "localhost" + +def single_or_list(type_): + return Union[type_, List[type_]] + + +class SMTPSchema(SchemaModel): + message: str = Field(..., description="The content of the email message") + subject: str = Field( + "New email from 'notifiers'!", description="The subject of the email message" + ) + to: single_or_list(EmailStr) = Field( + ..., description="One or more email addresses to use" + ) + from_: single_or_list(EmailStr) = Field( + f"{getpass.getuser()}@{socket.getfqdn()}", + description="One or more FROM addresses to use", + alias="from", + title="from", + ) + attachment: single_or_list(FilePath) = Field( + None, description="One or more attachments to use in the email" + ) + hostname: AnyUrl = Field("localhost", description="The host of the SMTP server") + port: StrictInt = Field(25, gt=0, lte=65535, description="The port number to use") + username: str = Field(None, description="Username if relevant") + password: str = Field(None, description="Password if relevant") + tls: bool = Field(False, description="Should TLS be used") + ssl: bool = Field(False, description="Should SSL be used") + html: bool = Field(False, description="Should the content be parsed as HTML") + login: bool = Field(True, description="Should login be triggered to the server") + + @root_validator(pre=True) + def username_password_check(cls, values): + if "password" in values and "username" not in values: + raise ValueError("Cannot set password without sending a username") + return values + + @validator("to", "from_", "attachment") + def values_to_list(cls, v): + return cls.to_list(v) class SMTP(Provider): @@ -28,65 +73,7 @@ class SMTP(Provider): site_url = "https://en.wikipedia.org/wiki/Email" name = "email" - _required = {"required": ["message", "to", "username", "password"]} - - _schema = { - "type": "object", - "properties": { - "message": {"type": "string", "title": "the content of the email message"}, - "subject": {"type": "string", "title": "the subject of the email message"}, - "to": one_or_more( - { - "type": "string", - "format": "email", - "title": "one or more email addresses to use", - } - ), - "from": { - "type": "string", - "format": "email", - "title": "the FROM address to use in the email", - }, - "from_": { - "type": "string", - "format": "email", - "title": "the FROM address to use in the email", - "duplicate": True, - }, - "attachments": one_or_more( - { - "type": "string", - "format": "valid_file", - "title": "one or more attachments to use in the email", - } - ), - "host": { - "type": "string", - "format": "hostname", - "title": "the host of the SMTP server", - }, - "port": { - "type": "integer", - "format": "port", - "title": "the port number to use", - }, - "username": {"type": "string", "title": "username if relevant"}, - "password": {"type": "string", "title": "password if relevant"}, - "tls": {"type": "boolean", "title": "should TLS be used"}, - "ssl": {"type": "boolean", "title": "should SSL be used"}, - "html": { - "type": "boolean", - "title": "should the email be parse as an HTML file", - }, - "login": {"type": "boolean", "title": "Trigger login to server"}, - }, - "dependencies": { - "username": ["password"], - "password": ["username"], - "ssl": ["tls"], - }, - "additionalProperties": False, - } + schema_model = SMTPSchema @staticmethod def _get_mimetype(attachment: Path) -> Tuple[str, str]: @@ -104,27 +91,6 @@ def __init__(self): self.smtp_server = None self.configuration = None - @property - def defaults(self) -> dict: - return { - "subject": DEFAULT_SUBJECT, - "from": DEFAULT_FROM, - "host": DEFAULT_SMTP_HOST, - "port": 25, - "tls": False, - "ssl": False, - "html": False, - "login": True, - } - - def _prepare_data(self, data: dict) -> dict: - if isinstance(data["to"], list): - data["to"] = list_to_commas(data["to"]) - # A workaround since `from` is a reserved word - if data.get("from_"): - data["from"] = data.pop("from_") - return data - @staticmethod def _build_email(data: dict) -> EmailMessage: email = EmailMessage() @@ -136,9 +102,8 @@ def _build_email(data: dict) -> EmailMessage: email.add_alternative(data["message"], subtype=content_type) return email - def _add_attachments(self, attachments: List[str], email: EmailMessage): + def _add_attachments(self, attachments: List[Path], email: EmailMessage): for attachment in attachments: - attachment = Path(attachment) maintype, subtype = self._get_mimetype(attachment) email.add_attachment( attachment.read_bytes(), From 7df19cfd412b76344f944bc14b83575cb7b8ff0e Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 23 Feb 2020 11:58:14 +0200 Subject: [PATCH 003/137] finished smtp --- notifiers/core.py | 2 -- notifiers/models/provider.py | 22 ++++++++++++---------- notifiers/providers/__init__.py | 29 +++++++++++++++-------------- notifiers/providers/email.py | 18 ++++++------------ 4 files changed, 33 insertions(+), 38 deletions(-) diff --git a/notifiers/core.py b/notifiers/core.py index 20df3b0c..a93033fd 100644 --- a/notifiers/core.py +++ b/notifiers/core.py @@ -4,8 +4,6 @@ from .models.provider import Provider from .models.response import Response -DEFAULT_ENVIRON_PREFIX = "NOTIFIERS_" - log = logging.getLogger("notifiers") FAILURE_STATUS = "Failure" diff --git a/notifiers/models/provider.py b/notifiers/models/provider.py index 484bedfb..2af8fee1 100644 --- a/notifiers/models/provider.py +++ b/notifiers/models/provider.py @@ -1,18 +1,20 @@ from abc import ABC from abc import abstractmethod +from typing import List +from typing import Union import requests from pydantic import BaseModel from pydantic import ValidationError -from notifiers.core import DEFAULT_ENVIRON_PREFIX -from notifiers.core import log from notifiers.exceptions import BadArguments from notifiers.models.response import Response from notifiers.models.response import ResponseStatus from notifiers.utils.helpers import dict_from_environs from notifiers.utils.helpers import merge_dicts +DEFAULT_ENVIRON_PREFIX = "NOTIFIERS_" + class SchemaModel(BaseModel): """The base class for Schemas""" @@ -23,6 +25,10 @@ def to_list(value): return [value] return value + @staticmethod + def single_or_list(type_): + return Union[type_, List[type_]] + class SchemaResource(ABC): """Base class that represent an object schema and its utility methods""" @@ -69,7 +75,7 @@ def create_response( errors=errors, ) - def _get_environs(self, prefix: str = None) -> dict: + def _get_environs(self, prefix: str = DEFAULT_ENVIRON_PREFIX) -> dict: """ Fetches set environment variables if such exist, via the :func:`~notifiers.utils.helpers.dict_from_environs` Searches for `[PREFIX_NAME]_[PROVIDER_NAME]_[ARGUMENT]` for each of the arguments defined in the schema @@ -77,12 +83,9 @@ def _get_environs(self, prefix: str = None) -> dict: :param prefix: The environ prefix to use. If not supplied, uses the default :return: A dict of arguments and value retrieved from environs """ - if not prefix: - log.debug("using default environ prefix") - prefix = DEFAULT_ENVIRON_PREFIX return dict_from_environs(prefix, self.name, list(self.arguments.keys())) - def _process_data(self, **data) -> dict: + def _process_data(self, data: dict) -> dict: """ The main method that process all resources data. Validates schema, gets environs, validates data, prepares it via provider requirements, merges defaults and check for data dependencies @@ -92,8 +95,7 @@ def _process_data(self, **data) -> dict: """ env_prefix = data.pop("env_prefix", None) environs = self._get_environs(env_prefix) - if environs: - data = merge_dicts(data, environs) + data = merge_dicts(data, environs) data = self.validate_data(data).dict() return data @@ -154,7 +156,7 @@ def notify(self, raise_on_errors: bool = False, **kwargs) -> Response: :raises: :class:`~notifiers.exceptions.NotificationError` if ``raise_on_errors`` is set to True and response contained errors """ - data = self._process_data(**kwargs) + data = self._process_data(kwargs) rsp = self._send_notification(data) if raise_on_errors: rsp.raise_on_errors() diff --git a/notifiers/providers/__init__.py b/notifiers/providers/__init__.py index 41f0f7aa..79aeefb1 100644 --- a/notifiers/providers/__init__.py +++ b/notifiers/providers/__init__.py @@ -1,3 +1,4 @@ +# flake8: noqa from . import email from . import gitter from . import gmail @@ -16,20 +17,20 @@ from . import zulip _all_providers = { - "pushover": pushover.Pushover, - "simplepush": simplepush.SimplePush, - "slack": slack.Slack, + # "pushover": pushover.Pushover, + # "simplepush": simplepush.SimplePush, + # "slack": slack.Slack, "email": email.SMTP, "gmail": gmail.Gmail, - "telegram": telegram.Telegram, - "gitter": gitter.Gitter, - "pushbullet": pushbullet.Pushbullet, - "join": join.Join, - "hipchat": hipchat.HipChat, - "zulip": zulip.Zulip, - "twilio": twilio.Twilio, - "pagerduty": pagerduty.PagerDuty, - "mailgun": mailgun.MailGun, - "popcornnotify": popcornnotify.PopcornNotify, - "statuspage": statuspage.Statuspage, + # "telegram": telegram.Telegram, + # "gitter": gitter.Gitter, + # "pushbullet": pushbullet.Pushbullet, + # "join": join.Join, + # "hipchat": hipchat.HipChat, + # "zulip": zulip.Zulip, + # "twilio": twilio.Twilio, + # "pagerduty": pagerduty.PagerDuty, + # "mailgun": mailgun.MailGun, + # "popcornnotify": popcornnotify.PopcornNotify, + # "statuspage": statuspage.Statuspage, } diff --git a/notifiers/providers/email.py b/notifiers/providers/email.py index 6810366a..0cf4f26c 100644 --- a/notifiers/providers/email.py +++ b/notifiers/providers/email.py @@ -10,14 +10,12 @@ from smtplib import SMTPServerDisconnected from typing import List from typing import Tuple -from typing import Union from pydantic import AnyUrl from pydantic import EmailStr from pydantic import Field from pydantic import FilePath from pydantic import root_validator -from pydantic import StrictInt from pydantic import validator from ..models.provider import Provider @@ -25,29 +23,25 @@ from ..models.response import Response -def single_or_list(type_): - return Union[type_, List[type_]] - - class SMTPSchema(SchemaModel): message: str = Field(..., description="The content of the email message") subject: str = Field( "New email from 'notifiers'!", description="The subject of the email message" ) - to: single_or_list(EmailStr) = Field( + to: SchemaModel.single_or_list(EmailStr) = Field( ..., description="One or more email addresses to use" ) - from_: single_or_list(EmailStr) = Field( + from_: SchemaModel.single_or_list(EmailStr) = Field( f"{getpass.getuser()}@{socket.getfqdn()}", description="One or more FROM addresses to use", alias="from", title="from", ) - attachment: single_or_list(FilePath) = Field( + attachments: SchemaModel.single_or_list(FilePath) = Field( None, description="One or more attachments to use in the email" ) - hostname: AnyUrl = Field("localhost", description="The host of the SMTP server") - port: StrictInt = Field(25, gt=0, lte=65535, description="The port number to use") + host: AnyUrl = Field("localhost", description="The host of the SMTP server") + port: int = Field(25, gt=0, lte=65535, description="The port number to use") username: str = Field(None, description="Username if relevant") password: str = Field(None, description="Password if relevant") tls: bool = Field(False, description="Should TLS be used") @@ -61,7 +55,7 @@ def username_password_check(cls, values): raise ValueError("Cannot set password without sending a username") return values - @validator("to", "from_", "attachment") + @validator("to", "from_", "attachments") def values_to_list(cls, v): return cls.to_list(v) From 0d19c2d19e1f8686119583379c55c5b1ee880e77 Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 23 Feb 2020 12:14:50 +0200 Subject: [PATCH 004/137] made email work with schema model --- notifiers/models/provider.py | 7 ++++--- notifiers/providers/email.py | 36 ++++++++++++++++++------------------ notifiers/providers/gmail.py | 22 +++++++++++++--------- 3 files changed, 35 insertions(+), 30 deletions(-) diff --git a/notifiers/models/provider.py b/notifiers/models/provider.py index 2af8fee1..a35c334e 100644 --- a/notifiers/models/provider.py +++ b/notifiers/models/provider.py @@ -66,6 +66,7 @@ def create_response( :param response: :class:`requests.Response` if exist :param errors: List of errors if relevant """ + # todo save both original and validated data, add to the response status = ResponseStatus.FAILURE if errors else ResponseStatus.SUCCESS return Response( status=status, @@ -85,7 +86,7 @@ def _get_environs(self, prefix: str = DEFAULT_ENVIRON_PREFIX) -> dict: """ return dict_from_environs(prefix, self.name, list(self.arguments.keys())) - def _process_data(self, data: dict) -> dict: + def _process_data(self, data: dict) -> SchemaModel: """ The main method that process all resources data. Validates schema, gets environs, validates data, prepares it via provider requirements, merges defaults and check for data dependencies @@ -97,7 +98,7 @@ def _process_data(self, data: dict) -> dict: environs = self._get_environs(env_prefix) data = merge_dicts(data, environs) - data = self.validate_data(data).dict() + data = self.validate_data(data) return data @@ -137,7 +138,7 @@ def resources(self) -> list: return list(self._resources.keys()) @abstractmethod - def _send_notification(self, data: dict) -> Response: + def _send_notification(self, data: SchemaModel) -> Response: """ The core method to trigger the provider notification. Must be overridden. diff --git a/notifiers/providers/email.py b/notifiers/providers/email.py index 0cf4f26c..4cb41853 100644 --- a/notifiers/providers/email.py +++ b/notifiers/providers/email.py @@ -86,14 +86,14 @@ def __init__(self): self.configuration = None @staticmethod - def _build_email(data: dict) -> EmailMessage: + def _build_email(data: SMTPSchema) -> EmailMessage: email = EmailMessage() - email["To"] = data["to"] - email["From"] = data["from"] - email["Subject"] = data["subject"] + email["To"] = data.to + email["From"] = data.from_ + email["Subject"] = data.subject email["Date"] = formatdate(localtime=True) - content_type = "html" if data["html"] else "plain" - email.add_alternative(data["message"], subtype=content_type) + content_type = "html" if data.html else "plain" + email.add_alternative(data.message, subtype=content_type) return email def _add_attachments(self, attachments: List[Path], email: EmailMessage): @@ -106,22 +106,22 @@ def _add_attachments(self, attachments: List[Path], email: EmailMessage): filename=attachment.name, ) - def _connect_to_server(self, data: dict): - self.smtp_server = smtplib.SMTP_SSL if data["ssl"] else smtplib.SMTP - self.smtp_server = self.smtp_server(data["host"], data["port"]) + def _connect_to_server(self, data: SMTPSchema): + self.smtp_server = smtplib.SMTP_SSL if data.ssl else smtplib.SMTP + self.smtp_server = self.smtp_server(data.host, data.port) self.configuration = self._get_configuration(data) - if data["tls"] and not data["ssl"]: + if data.tls and not data.ssl: self.smtp_server.ehlo() self.smtp_server.starttls() - if data["login"] and data.get("username"): - self.smtp_server.login(data["username"], data["password"]) + if data.login and data.username: + self.smtp_server.login(data.username, data.password) @staticmethod - def _get_configuration(data: dict) -> tuple: - return data["host"], data["port"], data.get("username") + def _get_configuration(data: SMTPSchema) -> tuple: + return data.host, data.port, data.username - def _send_notification(self, data: dict) -> Response: + def _send_notification(self, data: SMTPSchema) -> Response: errors = None try: configuration = self._get_configuration(data) @@ -132,8 +132,8 @@ def _send_notification(self, data: dict) -> Response: ): self._connect_to_server(data) email = self._build_email(data) - if data.get("attachments"): - self._add_attachments(data["attachments"], email) + if data.attachments: + self._add_attachments(data.attachments, email) self.smtp_server.send_message(email) except ( SMTPServerDisconnected, @@ -144,4 +144,4 @@ def _send_notification(self, data: dict) -> Response: SMTPAuthenticationError, ) as e: errors = [str(e)] - return self.create_response(data, errors=errors) + return self.create_response(data.dict(), errors=errors) diff --git a/notifiers/providers/gmail.py b/notifiers/providers/gmail.py index 3ec79596..4ccfc8a8 100644 --- a/notifiers/providers/gmail.py +++ b/notifiers/providers/gmail.py @@ -1,17 +1,21 @@ +from pydantic import AnyUrl +from pydantic import Field + from . import email +GMAIL_SMTP_HOST = "smtp.gmail.com" + + +class GmailSchema(email.SMTPSchema): + host: AnyUrl = Field(GMAIL_SMTP_HOST, description="The host of the SMTP server") + port: int = Field(587, gt=0, lte=65535, description="The port number to use") + tls: bool = Field(True, description="Should TLS be used") + class Gmail(email.SMTP): """Send email via Gmail""" site_url = "https://www.google.com/gmail/about/" - base_url = "smtp.gmail.com" + base_url = GMAIL_SMTP_HOST name = "gmail" - - @property - def defaults(self) -> dict: - data = super().defaults - data["host"] = self.base_url - data["port"] = 587 - data["tls"] = True - return data + schema_model = GmailSchema From d74c4a06b591dd78a5056104ed7a000e2b6f0a30 Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 23 Feb 2020 12:18:26 +0200 Subject: [PATCH 005/137] added base config to model --- notifiers/models/provider.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/notifiers/models/provider.py b/notifiers/models/provider.py index a35c334e..4f71f34d 100644 --- a/notifiers/models/provider.py +++ b/notifiers/models/provider.py @@ -5,6 +5,7 @@ import requests from pydantic import BaseModel +from pydantic import Extra from pydantic import ValidationError from notifiers.exceptions import BadArguments @@ -29,6 +30,10 @@ def to_list(value): def single_or_list(type_): return Union[type_, List[type_]] + class Config: + allow_population_by_field_name = True + extra = Extra.forbid + class SchemaResource(ABC): """Base class that represent an object schema and its utility methods""" From 6ec45478e70705fa995dbb76842eac721421351b Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 23 Feb 2020 12:43:25 +0200 Subject: [PATCH 006/137] refactored gitter --- notifiers/exceptions.py | 1 + notifiers/models/provider.py | 2 +- notifiers/providers/__init__.py | 2 +- notifiers/providers/gitter.py | 67 +++++++++++++++------------------ 4 files changed, 33 insertions(+), 39 deletions(-) diff --git a/notifiers/exceptions.py b/notifiers/exceptions.py index 03ed0fa7..986b9e44 100644 --- a/notifiers/exceptions.py +++ b/notifiers/exceptions.py @@ -61,6 +61,7 @@ class NotificationError(NotifierException): """ def __init__(self, *args, **kwargs): + # todo improve visibility of original exception self.errors = kwargs.pop("errors", None) kwargs["message"] = f'Notification errors: {",".join(self.errors)}' super().__init__(*args, **kwargs) diff --git a/notifiers/models/provider.py b/notifiers/models/provider.py index 4f71f34d..51a4899d 100644 --- a/notifiers/models/provider.py +++ b/notifiers/models/provider.py @@ -182,7 +182,7 @@ def _get_resource(self, data: dict): pass def __call__(self, **kwargs): - data = self._process_data(**kwargs) + data = self._process_data(kwargs) return self._get_resource(data) def __repr__(self): diff --git a/notifiers/providers/__init__.py b/notifiers/providers/__init__.py index 79aeefb1..487fdc60 100644 --- a/notifiers/providers/__init__.py +++ b/notifiers/providers/__init__.py @@ -23,7 +23,7 @@ "email": email.SMTP, "gmail": gmail.Gmail, # "telegram": telegram.Telegram, - # "gitter": gitter.Gitter, + "gitter": gitter.Gitter, # "pushbullet": pushbullet.Pushbullet, # "join": join.Join, # "hipchat": hipchat.HipChat, diff --git a/notifiers/providers/gitter.py b/notifiers/providers/gitter.py index c499f2a2..6223eebe 100644 --- a/notifiers/providers/gitter.py +++ b/notifiers/providers/gitter.py @@ -1,10 +1,26 @@ +from urllib.parse import urljoin + +from pydantic import Field + from ..exceptions import ResourceError from ..models.provider import Provider from ..models.provider import ProviderResource +from ..models.provider import SchemaModel from ..models.response import Response from ..utils import requests +class GitterRoomSchema(SchemaModel): + token: str = Field(..., description="Access token") + filter: str = Field(None, description="Filter results") + + +class GitterSchema(SchemaModel): + text: str = Field(..., description="Body of the message", alias="message") + token: str = Field(..., description="Access token") + room_id: str = Field(..., description="ID of the room to send the notification to") + + class GitterMixin: """Shared attributes between :class:`~notifiers.providers.gitter.GitterRooms` and :class:`~notifiers.providers.gitter.Gitter`""" @@ -13,7 +29,8 @@ class GitterMixin: path_to_errors = "errors", "error" base_url = "https://api.gitter.im/v1/rooms" - def _get_headers(self, token: str) -> dict: + @staticmethod + def _get_headers(token: str) -> dict: """ Builds Gitter requests header bases on the token provided @@ -27,22 +44,14 @@ class GitterRooms(GitterMixin, ProviderResource): """Returns a list of Gitter rooms via token""" resource_name = "rooms" + schema_model = GitterRoomSchema + + def _get_resource(self, data: GitterRoomSchema) -> list: + headers = self._get_headers(data.token) + params = {} + if data.filter: + params["q"] = data.filter - _required = {"required": ["token"]} - - _schema = { - "type": "object", - "properties": { - "token": {"type": "string", "title": "access token"}, - "filter": {"type": "string", "title": "Filter results"}, - }, - "additionalProperties": False, - } - - def _get_resource(self, data: dict) -> list: - headers = self._get_headers(data["token"]) - filter_ = data.get("filter") - params = {"q": filter_} if filter_ else {} response, errors = requests.get( self.base_url, headers=headers, @@ -58,7 +67,7 @@ def _get_resource(self, data: dict) -> list: response=response, ) rsp = response.json() - return rsp["results"] if filter_ else rsp + return rsp["results"] if data.filter else rsp class Gitter(GitterMixin, Provider): @@ -66,36 +75,20 @@ class Gitter(GitterMixin, Provider): message_url = "/{room_id}/chatMessages" site_url = "https://gitter.im" + schema_model = GitterSchema _resources = {"rooms": GitterRooms()} - _required = {"required": ["message", "token", "room_id"]} - _schema = { - "type": "object", - "properties": { - "message": {"type": "string", "title": "Body of the message"}, - "token": {"type": "string", "title": "access token"}, - "room_id": { - "type": "string", - "title": "ID of the room to send the notification to", - }, - }, - "additionalProperties": False, - } - - def _prepare_data(self, data: dict) -> dict: - data["text"] = data.pop("message") - return data - @property def metadata(self) -> dict: metadata = super().metadata metadata["message_url"] = self.message_url return metadata - def _send_notification(self, data: dict) -> Response: + def _send_notification(self, data: GitterSchema) -> Response: + data = data.dict() room_id = data.pop("room_id") - url = self.base_url + self.message_url.format(room_id=room_id) + url = urljoin(self.base_url, self.message_url.format(room_id=room_id)) headers = self._get_headers(data.pop("token")) response, errors = requests.post( From 80037b2f2d9cbb3f695fb2ef64e76861c3014b97 Mon Sep 17 00:00:00 2001 From: liiight Date: Tue, 25 Feb 2020 15:49:38 +0200 Subject: [PATCH 007/137] converted provider --- notifiers/providers/join.py | 249 +++++++++++++++++------------------- 1 file changed, 116 insertions(+), 133 deletions(-) diff --git a/notifiers/providers/join.py b/notifiers/providers/join.py index 6b6e76b6..061a6db9 100644 --- a/notifiers/providers/join.py +++ b/notifiers/providers/join.py @@ -1,13 +1,18 @@ import json +from urllib.parse import urljoin import requests +from pydantic import Extra +from pydantic import Field +from pydantic import HttpUrl +from pydantic import root_validator +from pydantic import validator from ..exceptions import ResourceError from ..models.provider import Provider from ..models.provider import ProviderResource +from ..models.provider import SchemaModel from ..models.response import Response -from ..utils.schema.helpers import list_to_commas -from ..utils.schema.helpers import one_or_more class JoinMixin: @@ -40,21 +45,22 @@ def _join_request(url: str, data: dict) -> tuple: return response, errors +class JoinBaseSchema(SchemaModel): + api_key: str = Field(..., description="User API key", alias="apikey") + + class Config: + extra = Extra.forbid + + class JoinDevices(JoinMixin, ProviderResource): """Return a list of Join devices IDs""" resource_name = "devices" devices_url = "/listDevices" - _required = {"required": ["apikey"]} - - _schema = { - "type": "object", - "properties": {"apikey": {"type": "string", "title": "user API key"}}, - "additionalProperties": False, - } + schema_model = JoinBaseSchema def _get_resource(self, data: dict): - url = self.base_url + self.devices_url + url = urljoin(self.base_url, self.devices_url) response, errors = self._join_request(url, data) if errors: raise ResourceError( @@ -67,6 +73,104 @@ def _get_resource(self, data: dict): return response.json()["records"] +class JoinSchema(JoinBaseSchema): + message: str = Field( + ..., + alias="text", + description="Usually used as a Tasker or EventGhost command." + " Can also be used with URLs and Files to add a description for those elements", + ) + device_id: str = Field( + "group.all", + description="The device ID or group ID of the device you want to send the message to", + alias="deviceId", + ) + device_ids: SchemaModel.single_or_list(str) = Field( + None, + description="A comma separated list of device IDs you want to send the push to", + alias="deviceIds", + ) + device_names: SchemaModel.single_or_list(str) = Field( + None, + description="A comma separated list of device names you want to send the push to", + alias="deviceNames", + ) + url: HttpUrl = Field( + None, + description="A URL you want to open on the device. If a notification is created with this push, " + "this will make clicking the notification open this URL", + ) + clipboard: str = Field( + None, + description="Some text you want to set on the receiving device’s clipboard", + ) + file: HttpUrl = Field(None, description="A publicly accessible URL of a file") + mms_file: HttpUrl = Field( + None, description="A publicly accessible MMS file URL", alias="mmsfile" + ) + wallpaper: HttpUrl = Field( + None, description="A publicly accessible URL of an image file" + ) + icon: HttpUrl = Field(None, description="Notification's icon URL") + small_icon: HttpUrl = Field( + None, description="Status Bar Icon URL", alias="smallicon" + ) + image: HttpUrl = Field(None, description="Notification image URL") + sms_number: str = Field( + None, description="Phone number to send an SMS to", alias="smsnumber" + ) + sms_text: str = Field( + None, description="Some text to send in an SMS", alias="smstext" + ) + call_number: str = Field(None, description="Number to call to", alias="callnumber") + interruption_filter: int = Field( + None, + gt=0, + lt=5, + description="set interruption filter mode", + alias="interruptionFilter", + ) + media_volume: int = Field( + None, description="Set device media volume", alias="mediaVolume" + ) + ring_volume: int = Field( + None, description="Set device ring volume", alias="ringVolume" + ) + alarm_volume: int = Field( + None, description="Set device alarm volume", alias="alarmVolume" + ) + find: bool = Field(None, description="Set to true to make your device ring loudly") + title: str = Field( + None, + description="If used, will always create a notification on the receiving device with " + "this as the title and text as the notification’s text", + ) + priority: int = Field( + None, gt=-3, lt=3, description="Control how your notification is displayed" + ) + group: str = Field( + None, description="Allows you to join notifications in different groups" + ) + + @root_validator(pre=True) + def sms_validation(cls, values): + if "sms_number" in values and not any( + value in values for value in ("sms_text", "mms_file") + ): + raise ValueError( + "Must use either 'sms_text' or 'mms_file' with 'sms_number'" + ) + return values + + @validator("device_ids", "device_names") + def values_to_list(cls, v): + return cls.to_list(v) + + class Config: + extra = Extra.forbid + allow_population_by_field_name = True + + class Join(JoinMixin, Provider): """Send Join notifications""" @@ -74,131 +178,10 @@ class Join(JoinMixin, Provider): site_url = "https://joaoapps.com/join/api/" _resources = {"devices": JoinDevices()} - - _required = { - "dependencies": {"smstext": ["smsnumber"], "callnumber": ["smsnumber"]}, - "anyOf": [ - {"dependencies": {"smsnumber": ["smstext"]}}, - {"dependencies": {"smsnumber": ["mmsfile"]}}, - ], - "error_anyOf": "Must use either 'smstext' or 'mmsfile' with 'smsnumber'", - "required": ["apikey", "message"], - } - - _schema = { - "type": "object", - "properties": { - "message": { - "type": "string", - "title": "usually used as a Tasker or EventGhost command. Can also be used with URLs and Files " - "to add a description for those elements", - }, - "apikey": {"type": "string", "title": "user API key"}, - "deviceId": { - "type": "string", - "title": "The device ID or group ID of the device you want to send the message to", - }, - "deviceIds": one_or_more( - { - "type": "string", - "title": "A comma separated list of device IDs you want to send the push to", - } - ), - "deviceNames": one_or_more( - { - "type": "string", - "title": "A comma separated list of device names you want to send the push to", - } - ), - "url": { - "type": "string", - "format": "uri", - "title": " A URL you want to open on the device. If a notification is created with this push, " - "this will make clicking the notification open this URL", - }, - "clipboard": { - "type": "string", - "title": "some text you want to set on the receiving device’s clipboard", - }, - "file": { - "type": "string", - "format": "uri", - "title": "a publicly accessible URL of a file", - }, - "smsnumber": {"type": "string", "title": "phone number to send an SMS to"}, - "smstext": {"type": "string", "title": "some text to send in an SMS"}, - "callnumber": {"type": "string", "title": "number to call to"}, - "interruptionFilter": { - "type": "integer", - "minimum": 1, - "maximum": 4, - "title": "set interruption filter mode", - }, - "mmsfile": { - "type": "string", - "format": "uri", - "title": "publicly accessible mms file url", - }, - "mediaVolume": {"type": "integer", "title": "set device media volume"}, - "ringVolume": {"type": "string", "title": "set device ring volume"}, - "alarmVolume": {"type": "string", "title": "set device alarm volume"}, - "wallpaper": { - "type": "string", - "format": "uri", - "title": "a publicly accessible URL of an image file", - }, - "find": { - "type": "boolean", - "title": "set to true to make your device ring loudly", - }, - "title": { - "type": "string", - "title": "If used, will always create a notification on the receiving device with this as the " - "title and text as the notification’s text", - }, - "icon": { - "type": "string", - "format": "uri", - "title": "notification's icon URL", - }, - "smallicon": { - "type": "string", - "format": "uri", - "title": "Status Bar Icon URL", - }, - "priority": { - "type": "integer", - "title": "control how your notification is displayed", - "minimum": -2, - "maximum": 2, - }, - "group": { - "type": "string", - "title": "allows you to join notifications in different groups", - }, - "image": { - "type": "string", - "format": "uri", - "title": "Notification image URL", - }, - }, - "additionalProperties": False, - } - - @property - def defaults(self) -> dict: - return {"deviceId": "group.all"} - - def _prepare_data(self, data: dict) -> dict: - if data.get("deviceIds"): - data["deviceIds"] = list_to_commas(data["deviceIds"]) - if data.get("deviceNames"): - data["deviceNames"] = list_to_commas(data["deviceNames"]) - data["text"] = data.pop("message") - return data + schema_model = JoinSchema def _send_notification(self, data: dict) -> Response: # Can 't use generic requests util since API doesn't always return error status - url = self.base_url + self.push_url + url = urljoin(self.base_url, self.push_url) response, errors = self._join_request(url, data) return self.create_response(data, response, errors) From 96805c8b510e902b2ee34ce6086826960a303559 Mon Sep 17 00:00:00 2001 From: liiight Date: Tue, 25 Feb 2020 15:55:41 +0200 Subject: [PATCH 008/137] converted provider --- notifiers/providers/join.py | 106 ++++++++++++++++++------------------ 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/notifiers/providers/join.py b/notifiers/providers/join.py index 061a6db9..3021383d 100644 --- a/notifiers/providers/join.py +++ b/notifiers/providers/join.py @@ -15,36 +15,6 @@ from ..models.response import Response -class JoinMixin: - """Shared resources between :class:`Join` and :class:`JoinDevices`""" - - name = "join" - base_url = "https://joinjoaomgcd.appspot.com/_ah/api/messaging/v1" - - @staticmethod - def _join_request(url: str, data: dict) -> tuple: - # Can 't use generic requests util since API doesn't always return error status - errors = None - try: - response = requests.get(url, params=data) - response.raise_for_status() - rsp = response.json() - if not rsp["success"]: - errors = [rsp["errorMessage"]] - except requests.RequestException as e: - if e.response is not None: - response = e.response - try: - errors = [response.json()["errorMessage"]] - except json.decoder.JSONDecodeError: - errors = [response.text] - else: - response = None - errors = [(str(e))] - - return response, errors - - class JoinBaseSchema(SchemaModel): api_key: str = Field(..., description="User API key", alias="apikey") @@ -52,27 +22,6 @@ class Config: extra = Extra.forbid -class JoinDevices(JoinMixin, ProviderResource): - """Return a list of Join devices IDs""" - - resource_name = "devices" - devices_url = "/listDevices" - schema_model = JoinBaseSchema - - def _get_resource(self, data: dict): - url = urljoin(self.base_url, self.devices_url) - response, errors = self._join_request(url, data) - if errors: - raise ResourceError( - errors=errors, - resource=self.resource_name, - provider=self.name, - data=data, - response=response, - ) - return response.json()["records"] - - class JoinSchema(JoinBaseSchema): message: str = Field( ..., @@ -171,6 +120,57 @@ class Config: allow_population_by_field_name = True +class JoinMixin: + """Shared resources between :class:`Join` and :class:`JoinDevices`""" + + name = "join" + base_url = "https://joinjoaomgcd.appspot.com/_ah/api/messaging/v1" + + @staticmethod + def _join_request(url: str, data: JoinBaseSchema) -> tuple: + # Can 't use generic requests util since API doesn't always return error status + errors = None + try: + response = requests.get(url, params=data.dict(by_alias=True)) + response.raise_for_status() + rsp = response.json() + if not rsp["success"]: + errors = [rsp["errorMessage"]] + except requests.RequestException as e: + if e.response is not None: + response = e.response + try: + errors = [response.json()["errorMessage"]] + except json.decoder.JSONDecodeError: + errors = [response.text] + else: + response = None + errors = [(str(e))] + + return response, errors + + +class JoinDevices(JoinMixin, ProviderResource): + """Return a list of Join devices IDs""" + + resource_name = "devices" + devices_url = "/listDevices" + schema_model = JoinBaseSchema + + def _get_resource(self, data: JoinBaseSchema): + url = urljoin(self.base_url, self.devices_url) + response, errors = self._join_request(url, data) + if errors: + raise ResourceError( + errors=errors, + resource=self.resource_name, + provider=self.name, + data=data, + response=response, + ) + return response.json()["records"] + + class Join(JoinMixin, Provider): """Send Join notifications""" @@ -180,8 +180,8 @@ class Join(JoinMixin, Provider): _resources = {"devices": JoinDevices()} schema_model = JoinSchema - def _send_notification(self, data: dict) -> Response: + def _send_notification(self, data: JoinSchema) -> Response: # Can 't use generic requests util since API doesn't always return error status url = urljoin(self.base_url, self.push_url) response, errors = self._join_request(url, data) - return self.create_response(data, response, errors) + return self.create_response(data.dict(), response, errors) From d1c41fc4cc566bff4238544b7494fecd1c26a138 Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 27 Feb 2020 00:59:01 +0200 Subject: [PATCH 009/137] formatted mailgun --- notifiers/models/provider.py | 9 +- notifiers/providers/mailgun.py | 367 +++++++++++++++++---------------- requirements.txt | 5 +- 3 files changed, 204 insertions(+), 177 deletions(-) diff --git a/notifiers/models/provider.py b/notifiers/models/provider.py index 51a4899d..17bff3de 100644 --- a/notifiers/models/provider.py +++ b/notifiers/models/provider.py @@ -1,5 +1,6 @@ from abc import ABC from abc import abstractmethod +from typing import Any from typing import List from typing import Union @@ -21,11 +22,17 @@ class SchemaModel(BaseModel): """The base class for Schemas""" @staticmethod - def to_list(value): + def to_list(value: Union[Any, List[Any]]) -> List[Any]: if not isinstance(value, list): return [value] return value + @staticmethod + def to_comma_separated(values: Union[Any, List[Any]]) -> str: + if not isinstance(values, list): + values = [values] + return ",".join(values) + @staticmethod def single_or_list(type_): return Union[type_, List[type_]] diff --git a/notifiers/providers/mailgun.py b/notifiers/providers/mailgun.py index 70eed9d6..374e8324 100644 --- a/notifiers/providers/mailgun.py +++ b/notifiers/providers/mailgun.py @@ -1,9 +1,198 @@ import json +from typing import Dict +from typing import Union + +from pendulum import DateTime +from pendulum import now +from pydantic import conint +from pydantic import EmailStr +from pydantic import Field +from pydantic import FilePath +from pydantic import Json +from pydantic import NameEmail +from pydantic import root_validator +from pydantic import validator +from typing_extensions import Literal from ..models.provider import Provider +from ..models.provider import SchemaModel from ..models.response import Response from ..utils import requests -from ..utils.schema.helpers import one_or_more + + +class MailGunSchema(SchemaModel): + api_key: str = Field(..., description="User's API key") + domain: str = Field(..., description="The domain to use") + from_: NameEmail = Field( + ..., description="Email address for 'From' header", alias="from" + ) + to: SchemaModel.single_or_list(NameEmail) = Field( + ..., description="Email address of the recipient(s)" + ) + cc: SchemaModel.single_or_list(NameEmail) = Field( + None, description="Email address of the CC recipient(s)" + ) + bcc: SchemaModel.single_or_list(NameEmail) = Field( + None, description="Email address of the BCC recipient(s)" + ) + subject: str = Field(None, description="Message subject") + message: str = Field( + None, description="Body of the message. (text version)", alias="text" + ) + html: str = Field(None, description="Body of the message. (HTML version)") + amp_html: str = Field( + None, + description="AMP part of the message. Please follow google guidelines to compose and send AMP emails.", + alias="amp-html", + ) + attachment: SchemaModel.single_or_list(FilePath) = Field( + None, description="File attachment(s)" + ) + inline: SchemaModel.single_or_list(FilePath) = Field( + None, + description="Attachment with inline disposition. Can be used to send inline images", + ) + template: str = Field( + None, description="Name of a template stored via template API" + ) + version: str = Field( + None, + description="Use this parameter to send a message to specific version of a template", + alias="t:version", + ) + t_text: bool = Field( + None, + description="Pass yes if you want to have rendered template in the text part of the message" + " in case of template sending", + alias="t:text", + ) + tag: SchemaModel.single_or_list(str) = Field( + None, + description="Tag string. See Tagging for more information", + alias="o:tag", + max_items=3, + max_length=128, + ) + dkim: bool = Field( + None, + description="Enables/disables DKIM signatures on per-message basis. Pass yes, no, true or false", + alias="o:dkim", + ) + delivery_time: DateTime = Field( + None, + description="Desired time of delivery. Note: Messages can be scheduled for a maximum of 3 days in the future.", + alias="o:deliverytime", + ) + delivery_time_optimize_period: conint(gt=23, lt=73) = Field( + None, + description="This value defines the time window (in hours) in which Mailgun will run the optimization " + "algorithm based on prior engagement data of a given recipient", + alias="o:deliverytime-optimize-period", + ) + test_mode: bool = Field( + None, description="Enables sending in test mode", alias="o:testmode" + ) + tracking: bool = Field( + None, description="Toggles tracking on a per-message basis", alias="o:tracking" + ) + tracking_clicks: Union[bool, Literal["htmlonly"]] = Field( + None, + description="Toggles clicks tracking on a per-message basis. Has higher priority than domain-level setting", + alias="o:tracking-clicks", + ) + tracking_opens: bool = Field( + None, + description="Toggles opens tracking on a per-message basis.", + alias="o:tracking-opens", + ) + require_tls: bool = Field( + None, + description="If set to True or yes this requires the message only be sent over a TLS connection." + " If a TLS connection can not be established, Mailgun will not deliver the message." + " If set to False or no, Mailgun will still try and upgrade the connection, " + "but if Mailgun can not, the message will be delivered over a plaintext SMTP connection", + alias="o:require-tls", + ) + skip_verification: bool = Field( + None, + description="If set to True or yes, the certificate and hostname will not be verified when trying to establish " + "a TLS connection and Mailgun will accept any certificate during delivery." + " If set to False or no, Mailgun will verify the certificate and hostname." + " If either one can not be verified, a TLS connection will not be established.", + alias="o:skip-verification", + ) + + headers: SchemaModel.single_or_list(Dict[str, str]) = Field( + None, + description="Add arbitrary value(s) to append a custom MIME header to the message", + ) + data: SchemaModel.single_or_list(Dict[str, Json]) = Field( + None, description="Attach a custom JSON data to the message" + ) + recipient_variables: Dict[EmailStr, Dict[str, str]] = Field( + None, + description="A valid JSON-encoded dictionary, where key is a plain recipient address and value is a " + "dictionary with variables that can be referenced in the message body.", + alias="recipient-variables", + ) + + @validator("tag", pre=True, each_item=True) + def validate_tag(cls, v): + if not isinstance(v, list): + v = [v] + for v_ in v: + try: + v_.encode("ascii") + except UnicodeEncodeError: + raise ValueError("Value must be valid ascii") + return v + + @root_validator() + def headers_and_data(cls, values): + def transform(key_name, prefix, json_dump): + data_to_transform = values.pop(key_name, None) + if data_to_transform: + if not isinstance(data_to_transform, list): + data_to_transform = [data_to_transform] + for data_ in data_to_transform: + for name, value in data_.items(): + if json_dump: + value = json.dumps(value) + values[f"{prefix}:{name}"] = value + + transform("headers", "h", False) + transform("data", "v", True) + return values + + @root_validator(pre=True) + def validate_body(cls, values): + if not any(value in values for value in ("message", "html")): + raise ValueError("Either 'text' or 'html' are required") + return values + + @validator("delivery_time_optimize_period") + def hours_to_str(cls, v): + return f"{v}h" + + @validator("delivery_time", pre=True) + def valid_delivery_time(cls, v: DateTime): + if v.diff(now("utc")).days > 3: + raise ValueError( + "Messages can be scheduled for a maximum of 3 days in the future" + ) + return v.to_rfc2822_string() + + @validator("t_text", "test_mode") + def true_to_yes(cls, v): + return "yes" if v else "no" + + @validator("dkim", "tracking", "tracking_clicks", "tracking_opens") + def text_bool(cls, v): + return str(v).lower() if isinstance(v, bool) else v + + @validator("to", "cc", "bcc") + def comma(cls, v): + return cls.to_comma_separated(v) class MailGun(Provider): @@ -14,180 +203,8 @@ class MailGun(Provider): name = "mailgun" path_to_errors = ("message",) - __properties_to_change = [ - "tag", - "dkim", - "deliverytime", - "testmode", - "tracking", - "tracking_clicks", - "tracking_opens", - "require_tls", - "skip_verification", - ] - - __email_list = one_or_more( - { - "type": "string", - "title": 'Email address of the recipient(s). Example: "Bob ".', - } - ) - - _required = { - "allOf": [ - {"required": ["to", "domain", "api_key"]}, - {"anyOf": [{"required": ["from"]}, {"required": ["from_"]}]}, - { - "anyOf": [{"required": ["message"]}, {"required": ["html"]}], - "error_anyOf": 'Need either "message" or "html"', - }, - ] - } - - _schema = { - "type": "object", - "properties": { - "api_key": {"type": "string", "title": "User's API key"}, - "message": { - "type": "string", - "title": "Body of the message. (text version)", - }, - "html": {"type": "string", "title": "Body of the message. (HTML version)"}, - "to": __email_list, - "from": { - "type": "string", - "format": "email", - "title": "Email address for From header", - }, - "from_": { - "type": "string", - "format": "email", - "title": "Email address for From header", - "duplicate": True, - }, - "domain": {"type": "string", "title": "MailGun's domain to use"}, - "cc": __email_list, - "bcc": __email_list, - "subject": {"type": "string", "title": "Message subject"}, - "attachment": one_or_more( - {"type": "string", "format": "valid_file", "title": "File attachment"} - ), - "inline": one_or_more( - { - "type": "string", - "format": "valid_file", - "title": "Attachment with inline disposition. Can be used to send inline images", - } - ), - "tag": one_or_more( - schema={ - "type": "string", - "format": "ascii", - "title": "Tag string", - "maxLength": 128, - }, - max=3, - ), - "dkim": { - "type": "boolean", - "title": "Enables/disables DKIM signatures on per-message basis", - }, - "deliverytime": { - "type": "string", - "format": "rfc2822", - "title": "Desired time of delivery. Note: Messages can be scheduled for a maximum of 3 days in " - "the future.", - }, - "testmode": {"type": "boolean", "title": "Enables sending in test mode."}, - "tracking": { - "type": "boolean", - "title": "Toggles tracking on a per-message basis", - }, - "tracking_clicks": { - "type": ["string", "boolean"], - "title": "Toggles clicks tracking on a per-message basis. Has higher priority than domain-level" - " setting. Pass yes, no or htmlonly.", - "enum": [True, False, "htmlonly"], - }, - "tracking_opens": { - "type": "boolean", - "title": "Toggles opens tracking on a per-message basis. Has higher priority than domain-level setting", - }, - "require_tls": { - "type": "boolean", - "title": "If set to True this requires the message only be sent over a TLS connection." - " If a TLS connection can not be established, Mailgun will not deliver the message." - "If set to False, Mailgun will still try and upgrade the connection, but if Mailgun can not," - " the message will be delivered over a plaintext SMTP connection.", - }, - "skip_verification": { - "type": "boolean", - "title": "If set to True, the certificate and hostname will not be verified when trying to establish " - "a TLS connection and Mailgun will accept any certificate during delivery. If set to False," - " Mailgun will verify the certificate and hostname. If either one can not be verified, " - "a TLS connection will not be established.", - }, - "headers": { - "type": "object", - "additionalProperties": {"type": "string"}, - "title": "Any other header to add", - }, - "data": { - "type": "object", - "additionalProperties": {"type": "object"}, - "title": "attach a custom JSON data to the message", - }, - }, - "additionalProperties": False, - } - - def _prepare_data(self, data: dict) -> dict: - if data.get("from_"): - data["from"] = data.pop("from_") - - new_data = { - "to": data.pop("to"), - "from": data.pop("from"), - "domain": data.pop("domain"), - "api_key": data.pop("api_key"), - } - - if data.get("message"): - new_data["text"] = data.pop("message") - - if data.get("attachment"): - attachment = data.pop("attachment") - if isinstance(attachment, str): - attachment = [attachment] - new_data["attachment"] = attachment - - if data.get("inline"): - inline = data.pop("inline") - if isinstance(inline, str): - inline = [inline] - new_data["inline"] = inline - - for property_ in self.__properties_to_change: - if data.get(property_): - new_property = f"o:{property_}".replace("_", "-") - new_data[new_property] = data.pop(property_) - - if data.get("headers"): - for key, value in data["headers"].items(): - new_data[f"h:{key}"] = value - del data["headers"] - - if data.get("data"): - for key, value in data["data"].items(): - new_data[f"v:{key}"] = json.dumps(value) - del data["data"] - - for key, value in data.items(): - new_data[key] = value - - return new_data - - def _send_notification(self, data: dict) -> Response: + def _send_notification(self, data: MailGunSchema) -> Response: + data = data.dict(by_alias=True, exclude_none=True) url = self.base_url.format(domain=data.pop("domain")) auth = "api", data.pop("api_key") files = [] diff --git a/requirements.txt b/requirements.txt index 0cf2455c..93c9ea12 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,7 @@ requests>=2.21.0 jsonschema>=3.0.0 click>=7.0 -rfc3987>=1.3.8 \ No newline at end of file +rfc3987>=1.3.8 +pendulum +pydantic +glom \ No newline at end of file From 4712f3dbb97b171414433f48fb0ea06f9fe7502a Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 27 Feb 2020 11:24:43 +0200 Subject: [PATCH 010/137] fixed and verified mailgun --- notifiers/exceptions.py | 7 +++++- notifiers/models/provider.py | 13 ++++++----- notifiers/providers/__init__.py | 2 +- notifiers/providers/mailgun.py | 38 +++++++++++++++++---------------- notifiers/utils/requests.py | 8 ++++--- tests/providers/test_mailgun.py | 7 ++---- 6 files changed, 40 insertions(+), 35 deletions(-) diff --git a/notifiers/exceptions.py b/notifiers/exceptions.py index 986b9e44..7feac84f 100644 --- a/notifiers/exceptions.py +++ b/notifiers/exceptions.py @@ -1,3 +1,6 @@ +from pydantic import ValidationError + + class NotifierException(Exception): """Base notifier exception. Catch this to catch all of :mod:`notifiers` errors""" @@ -26,7 +29,9 @@ class BadArguments(NotifierException): :param kwargs: Exception kwargs """ - def __init__(self, validation_error: str, *args, **kwargs): + def __init__( + self, validation_error: str, orig_excp: ValidationError, *args, **kwargs + ): kwargs["message"] = f"Error with sent data: {validation_error}" super().__init__(*args, **kwargs) diff --git a/notifiers/models/provider.py b/notifiers/models/provider.py index 17bff3de..3fa5a260 100644 --- a/notifiers/models/provider.py +++ b/notifiers/models/provider.py @@ -31,11 +31,11 @@ def to_list(value: Union[Any, List[Any]]) -> List[Any]: def to_comma_separated(values: Union[Any, List[Any]]) -> str: if not isinstance(values, list): values = [values] - return ",".join(values) + return ",".join(str(value) for value in values) @staticmethod def single_or_list(type_): - return Union[type_, List[type_]] + return Union[List[type_], type_] class Config: allow_population_by_field_name = True @@ -64,9 +64,8 @@ def arguments(self) -> dict: def validate_data(self, data: dict) -> SchemaModel: try: return self.schema_model.parse_obj(data) - except ValidationError: - # todo handle validation error and return custom - raise BadArguments + except ValidationError as e: + raise BadArguments(validation_error=(str(e)), orig_excp=e) def create_response( self, data: dict = None, response: requests.Response = None, errors: list = None @@ -88,7 +87,7 @@ def create_response( errors=errors, ) - def _get_environs(self, prefix: str = DEFAULT_ENVIRON_PREFIX) -> dict: + def _get_environs(self, prefix: str) -> dict: """ Fetches set environment variables if such exist, via the :func:`~notifiers.utils.helpers.dict_from_environs` Searches for `[PREFIX_NAME]_[PROVIDER_NAME]_[ARGUMENT]` for each of the arguments defined in the schema @@ -106,7 +105,7 @@ def _process_data(self, data: dict) -> SchemaModel: :param data: The raw data passed by the notifiers client :return: Processed data """ - env_prefix = data.pop("env_prefix", None) + env_prefix = data.pop("env_prefix", DEFAULT_ENVIRON_PREFIX) environs = self._get_environs(env_prefix) data = merge_dicts(data, environs) diff --git a/notifiers/providers/__init__.py b/notifiers/providers/__init__.py index 487fdc60..31b2396b 100644 --- a/notifiers/providers/__init__.py +++ b/notifiers/providers/__init__.py @@ -30,7 +30,7 @@ # "zulip": zulip.Zulip, # "twilio": twilio.Twilio, # "pagerduty": pagerduty.PagerDuty, - # "mailgun": mailgun.MailGun, + "mailgun": mailgun.MailGun, # "popcornnotify": popcornnotify.PopcornNotify, # "statuspage": statuspage.Statuspage, } diff --git a/notifiers/providers/mailgun.py b/notifiers/providers/mailgun.py index 374e8324..98975d72 100644 --- a/notifiers/providers/mailgun.py +++ b/notifiers/providers/mailgun.py @@ -1,14 +1,15 @@ import json +import time +from datetime import datetime +from email import utils as email_utils from typing import Dict +from typing import List from typing import Union -from pendulum import DateTime -from pendulum import now from pydantic import conint from pydantic import EmailStr from pydantic import Field from pydantic import FilePath -from pydantic import Json from pydantic import NameEmail from pydantic import root_validator from pydantic import validator @@ -26,7 +27,7 @@ class MailGunSchema(SchemaModel): from_: NameEmail = Field( ..., description="Email address for 'From' header", alias="from" ) - to: SchemaModel.single_or_list(NameEmail) = Field( + to: SchemaModel.single_or_list(Union[EmailStr, NameEmail]) = Field( ..., description="Email address of the recipient(s)" ) cc: SchemaModel.single_or_list(NameEmail) = Field( @@ -66,7 +67,7 @@ class MailGunSchema(SchemaModel): " in case of template sending", alias="t:text", ) - tag: SchemaModel.single_or_list(str) = Field( + tag: List[str] = Field( None, description="Tag string. See Tagging for more information", alias="o:tag", @@ -78,7 +79,7 @@ class MailGunSchema(SchemaModel): description="Enables/disables DKIM signatures on per-message basis. Pass yes, no, true or false", alias="o:dkim", ) - delivery_time: DateTime = Field( + delivery_time: datetime = Field( None, description="Desired time of delivery. Note: Messages can be scheduled for a maximum of 3 days in the future.", alias="o:deliverytime", @@ -126,7 +127,7 @@ class MailGunSchema(SchemaModel): None, description="Add arbitrary value(s) to append a custom MIME header to the message", ) - data: SchemaModel.single_or_list(Dict[str, Json]) = Field( + data: Dict[str, dict] = Field( None, description="Attach a custom JSON data to the message" ) recipient_variables: Dict[EmailStr, Dict[str, str]] = Field( @@ -138,13 +139,10 @@ class MailGunSchema(SchemaModel): @validator("tag", pre=True, each_item=True) def validate_tag(cls, v): - if not isinstance(v, list): - v = [v] - for v_ in v: - try: - v_.encode("ascii") - except UnicodeEncodeError: - raise ValueError("Value must be valid ascii") + try: + v.encode("ascii") + except UnicodeEncodeError: + raise ValueError("Value must be valid ascii") return v @root_validator() @@ -174,13 +172,15 @@ def validate_body(cls, values): def hours_to_str(cls, v): return f"{v}h" - @validator("delivery_time", pre=True) - def valid_delivery_time(cls, v: DateTime): - if v.diff(now("utc")).days > 3: + @validator("delivery_time") + def valid_delivery_time(cls, v: datetime): + now = datetime.now() + delta = now - v + if delta.days > 3: raise ValueError( "Messages can be scheduled for a maximum of 3 days in the future" ) - return v.to_rfc2822_string() + return email_utils.formatdate(time.mktime(v.timetuple())) @validator("t_text", "test_mode") def true_to_yes(cls, v): @@ -203,6 +203,8 @@ class MailGun(Provider): name = "mailgun" path_to_errors = ("message",) + schema_model = MailGunSchema + def _send_notification(self, data: MailGunSchema) -> Response: data = data.dict(by_alias=True, exclude_none=True) url = self.base_url.format(domain=data.pop("domain")) diff --git a/notifiers/utils/requests.py b/notifiers/utils/requests.py index e3ca038f..37c658a6 100644 --- a/notifiers/utils/requests.py +++ b/notifiers/utils/requests.py @@ -1,5 +1,7 @@ import json import logging +from pathlib import Path +from typing import List import requests @@ -80,7 +82,7 @@ def post(url: str, *args, **kwargs) -> tuple: def file_list_for_request( - list_of_paths: list, key_name: str, mimetype: str = None + list_of_paths: List[Path], key_name: str, mimetype: str = None ) -> list: """ Convenience function to construct a list of files for multiple files upload by :mod:`requests` @@ -92,7 +94,7 @@ def file_list_for_request( """ if mimetype: return [ - (key_name, (file, open(file, mode="rb"), mimetype)) + (key_name, (file.name, file.read_bytes(), mimetype)) for file in list_of_paths ] - return [(key_name, (file, open(file, mode="rb"))) for file in list_of_paths] + return [(key_name, (file.name, file.read_bytes())) for file in list_of_paths] diff --git a/tests/providers/test_mailgun.py b/tests/providers/test_mailgun.py index 1986cd60..3264b1b3 100644 --- a/tests/providers/test_mailgun.py +++ b/tests/providers/test_mailgun.py @@ -1,6 +1,4 @@ import datetime -import time -from email import utils import pytest @@ -50,7 +48,6 @@ def test_mailgun_all_options(self, provider, tmpdir, test_message): file_2.write("content") now = datetime.datetime.now() + datetime.timedelta(minutes=3) - rfc_2822 = utils.formatdate(time.mktime(now.timetuple())) data = { "message": test_message, "html": f"{now}", @@ -59,8 +56,8 @@ def test_mailgun_all_options(self, provider, tmpdir, test_message): "inline": [file_1.strpath, file_2.strpath], "tag": ["foo", "bar"], "dkim": True, - "deliverytime": rfc_2822, - "testmode": False, + "delivery_time": now, + "test_mode": False, "tracking": True, "tracking_clicks": "htmlonly", "tracking_opens": True, From 4ea6a6cac1e5afd490e726b8eaa8ee6629aeaa2b Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 27 Feb 2020 12:39:00 +0200 Subject: [PATCH 011/137] fixed mail gun validation test --- tests/providers/test_mailgun.py | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/providers/test_mailgun.py b/tests/providers/test_mailgun.py index 3264b1b3..42fad626 100644 --- a/tests/providers/test_mailgun.py +++ b/tests/providers/test_mailgun.py @@ -19,19 +19,25 @@ def test_mailgun_metadata(self, provider): @pytest.mark.parametrize( "data, message", [ - ({}, "to"), - ({"to": "foo"}, "domain"), - ({"to": "foo", "domain": "bla"}, "api_key"), - ({"to": "foo", "domain": "bla", "api_key": "bla"}, "from"), + ({}, "Either 'text' or 'html' are required"), + ({"message": "foo"}, "api_key\n field required"), ( - {"to": "foo", "domain": "bla", "api_key": "bla", "from": "bbb"}, - "message", + {"message": "foo", "to": "non-email"}, + "to\n value is not a valid email address", + ), + ( + {"message": "foo", "to": "1@1.com", "api_key": "foo"}, + "domain\n field required", + ), + ( + {"message": "foo", "to": "1@1.com", "api_key": "foo", "domain": "foo"}, + "from\n field required", ), ], ) def test_mailgun_missing_required(self, data, message, provider): data["env_prefix"] = "test" - with pytest.raises(BadArguments, match=f"'{message}' is a required property"): + with pytest.raises(BadArguments, match=message): provider.notify(**data) @pytest.mark.online From 972bb31910910d19ded47d89315a39cedcd13695 Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 27 Feb 2020 12:41:35 +0200 Subject: [PATCH 012/137] fixed mailgun test --- tests/providers/test_mailgun.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/providers/test_mailgun.py b/tests/providers/test_mailgun.py index 42fad626..591ee17f 100644 --- a/tests/providers/test_mailgun.py +++ b/tests/providers/test_mailgun.py @@ -2,8 +2,8 @@ import pytest -from notifiers.core import FAILURE_STATUS from notifiers.exceptions import BadArguments +from notifiers.models.response import ResponseStatus provider = "mailgun" @@ -83,5 +83,5 @@ def test_mailgun_error_response(self, provider): "from": "foo@foo.com", } rsp = provider.notify(**data) - assert rsp.status == FAILURE_STATUS + assert rsp.status is ResponseStatus.FAILURE assert "Forbidden" in rsp.errors From bf769ac3c44bb19ea5e90a8441151aaaf9f3f59a Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 27 Feb 2020 12:54:12 +0200 Subject: [PATCH 013/137] fixed email and tests --- notifiers/providers/email.py | 9 ++++++--- notifiers/providers/gmail.py | 3 +-- tests/providers/test_gmail.py | 22 ++++++---------------- 3 files changed, 13 insertions(+), 21 deletions(-) diff --git a/notifiers/providers/email.py b/notifiers/providers/email.py index 4cb41853..50c5915c 100644 --- a/notifiers/providers/email.py +++ b/notifiers/providers/email.py @@ -11,7 +11,6 @@ from typing import List from typing import Tuple -from pydantic import AnyUrl from pydantic import EmailStr from pydantic import Field from pydantic import FilePath @@ -40,7 +39,7 @@ class SMTPSchema(SchemaModel): attachments: SchemaModel.single_or_list(FilePath) = Field( None, description="One or more attachments to use in the email" ) - host: AnyUrl = Field("localhost", description="The host of the SMTP server") + host: str = Field("localhost", description="The host of the SMTP server") port: int = Field(25, gt=0, lte=65535, description="The port number to use") username: str = Field(None, description="Username if relevant") password: str = Field(None, description="Password if relevant") @@ -55,10 +54,14 @@ def username_password_check(cls, values): raise ValueError("Cannot set password without sending a username") return values - @validator("to", "from_", "attachments") + @validator("attachments") def values_to_list(cls, v): return cls.to_list(v) + @validator("to", "from_") + def comma_separated(cls, v): + return cls.to_comma_separated(v) + class SMTP(Provider): """Send emails via SMTP""" diff --git a/notifiers/providers/gmail.py b/notifiers/providers/gmail.py index 4ccfc8a8..cb8256a4 100644 --- a/notifiers/providers/gmail.py +++ b/notifiers/providers/gmail.py @@ -1,4 +1,3 @@ -from pydantic import AnyUrl from pydantic import Field from . import email @@ -7,7 +6,7 @@ class GmailSchema(email.SMTPSchema): - host: AnyUrl = Field(GMAIL_SMTP_HOST, description="The host of the SMTP server") + host: str = Field(GMAIL_SMTP_HOST, description="The host of the SMTP server") port: int = Field(587, gt=0, lte=65535, description="The port number to use") tls: bool = Field(True, description="Should TLS be used") diff --git a/tests/providers/test_gmail.py b/tests/providers/test_gmail.py index 481c3871..15a26599 100644 --- a/tests/providers/test_gmail.py +++ b/tests/providers/test_gmail.py @@ -17,13 +17,16 @@ def test_gmail_metadata(self, provider): } @pytest.mark.parametrize( - "data, message", [({}, "message"), ({"message": "foo"}, "to")] + "data, message", + [ + ({}, "message\n field required"), + ({"message": "foo"}, "to\n field required"), + ], ) def test_gmail_missing_required(self, data, message, provider): data["env_prefix"] = "test" - with pytest.raises(BadArguments) as e: + with pytest.raises(BadArguments, match=message): provider.notify(**data) - assert f"'{message}' is a required property" in e.value.message @pytest.mark.online def test_smtp_sanity(self, provider, test_message): @@ -37,19 +40,6 @@ def test_smtp_sanity(self, provider, test_message): rsp = provider.notify(**data) rsp.raise_on_errors() - def test_email_from_key(self, provider): - rsp = provider.notify( - to="foo@foo.com", - from_="bla@foo.com", - message="foo", - host="goo", - username="ding", - password="dong", - ) - rsp_data = rsp.data - assert not rsp_data.get("from_") - assert rsp_data["from"] == "bla@foo.com" - def test_multiple_to(self, provider): to = ["foo@foo.com", "bar@foo.com"] rsp = provider.notify( From 060ad499cc22c98a6ac77ad351ac9a6016dc89ff Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 27 Feb 2020 13:05:35 +0200 Subject: [PATCH 014/137] fixed gitter --- notifiers/models/provider.py | 9 ++++++++- tests/providers/test_gitter.py | 23 ++++++----------------- 2 files changed, 14 insertions(+), 18 deletions(-) diff --git a/notifiers/models/provider.py b/notifiers/models/provider.py index 3fa5a260..b3793970 100644 --- a/notifiers/models/provider.py +++ b/notifiers/models/provider.py @@ -2,6 +2,7 @@ from abc import abstractmethod from typing import Any from typing import List +from typing import Optional from typing import Union import requests @@ -51,16 +52,22 @@ class SchemaResource(ABC): @abstractmethod def name(self) -> str: """Resource provider name""" - pass @property def schema(self) -> dict: + """Resource's JSON schema as a dict""" return self.schema_model.schema() @property def arguments(self) -> dict: + """Resource's arguments""" return self.schema["properties"] + @property + def required(self) -> Optional[List[str]]: + """Resource's required arguments. Note that additional validation may not be represented here""" + return self.schema.get("required") + def validate_data(self, data: dict) -> SchemaModel: try: return self.schema_model.parse_obj(data) diff --git a/tests/providers/test_gitter.py b/tests/providers/test_gitter.py index 2c232bcb..9151a012 100644 --- a/tests/providers/test_gitter.py +++ b/tests/providers/test_gitter.py @@ -19,16 +19,15 @@ def test_metadata(self, provider): @pytest.mark.parametrize( "data, message", [ - ({}, "message"), - ({"message": "foo"}, "token"), - ({"message": "foo", "token": "bar"}, "room_id"), + ({}, "message\n field required"), + ({"message": "foo"}, "token\n field required"), + ({"message": "foo", "token": "bar"}, "room_id\n field required"), ], ) def test_missing_required(self, provider, data, message): data["env_prefix"] = "test" - with pytest.raises(BadArguments) as e: + with pytest.raises(BadArguments, match=message): provider.notify(**data) - assert f"'{message}' is a required property" in e.value.message def test_bad_request(self, provider): data = {"token": "foo", "room_id": "baz", "message": "bar"} @@ -48,8 +47,7 @@ def test_bad_room_id(self, provider): @pytest.mark.online def test_sanity(self, provider, test_message): data = {"message": test_message} - rsp = provider.notify(**data) - rsp.raise_on_errors() + provider.notify(**data, raise_on_errors=True) def test_gitter_resources(self, provider): assert provider.resources @@ -62,17 +60,8 @@ class TestGitterResources: resource = "rooms" def test_gitter_rooms_attribs(self, resource): - assert resource.schema == { - "type": "object", - "properties": { - "token": {"type": "string", "title": "access token"}, - "filter": {"type": "string", "title": "Filter results"}, - }, - "required": ["token"], - "additionalProperties": False, - } assert resource.name == provider - assert resource.required == {"required": ["token"]} + assert resource.required == ["token"] def test_gitter_rooms_negative(self, resource): with pytest.raises(BadArguments): From b2f698c9ab218b6a85ff8d5d3866d9d7adb0e7b8 Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 27 Feb 2020 13:07:42 +0200 Subject: [PATCH 015/137] removed hipchat as its dead --- notifiers/providers/__init__.py | 2 - notifiers/providers/hipchat.py | 398 -------------------------------- tests/providers/test_hipchat.py | 175 -------------- 3 files changed, 575 deletions(-) delete mode 100644 notifiers/providers/hipchat.py delete mode 100644 tests/providers/test_hipchat.py diff --git a/notifiers/providers/__init__.py b/notifiers/providers/__init__.py index 31b2396b..d3d134bf 100644 --- a/notifiers/providers/__init__.py +++ b/notifiers/providers/__init__.py @@ -2,7 +2,6 @@ from . import email from . import gitter from . import gmail -from . import hipchat from . import join from . import mailgun from . import pagerduty @@ -26,7 +25,6 @@ "gitter": gitter.Gitter, # "pushbullet": pushbullet.Pushbullet, # "join": join.Join, - # "hipchat": hipchat.HipChat, # "zulip": zulip.Zulip, # "twilio": twilio.Twilio, # "pagerduty": pagerduty.PagerDuty, diff --git a/notifiers/providers/hipchat.py b/notifiers/providers/hipchat.py deleted file mode 100644 index 91444366..00000000 --- a/notifiers/providers/hipchat.py +++ /dev/null @@ -1,398 +0,0 @@ -import copy - -from ..exceptions import ResourceError -from ..models.provider import Provider -from ..models.provider import ProviderResource -from ..models.response import Response -from ..utils import requests - - -class HipChatMixin: - """Shared attributed between resources and :class:`HipChatResourceProxy`""" - - base_url = "https://{group}.hipchat.com" - name = "hipchat" - path_to_errors = "error", "message" - users_url = "/v2/user" - rooms_url = "/v2/room" - - def _get_headers(self, token: str) -> dict: - """ - Builds hipchat requests header bases on the token provided - - :param token: App token - :return: Authentication header dict - """ - return {"Authorization": f"Bearer {token}"} - - -class HipChatResourceMixin(HipChatMixin): - """Common resources attributes that should not override :class:`HipChat` attributes""" - - _required = { - "allOf": [ - {"required": ["token"]}, - { - "oneOf": [{"required": ["group"]}, {"required": ["team_server"]}], - "error_oneOf": "Only one 'group' or 'team_server' is allowed", - }, - ] - } - - _schema = { - "type": "object", - "properties": { - "token": {"type": "string", "title": "User token"}, - "start": {"type": "integer", "title": "Start index"}, - "max_results": {"type": "integer", "title": "Max results in reply"}, - "group": {"type": "string", "title": "Hipchat group name"}, - "team_server": {"type": "string", "title": "Hipchat team server"}, - }, - "additionalProperties": False, - } - - def _get_resources(self, endpoint: str, data: dict) -> tuple: - url = ( - self.base_url.format(group=data["group"]) - if data.get("group") - else data["team_server"] - ) - url += endpoint - headers = self._get_headers(data["token"]) - params = {} - if data.get("start"): - params["start-index"] = data["start"] - if data.get("max_results"): - params["max-results"] = data["max_results"] - if data.get("private"): - params["include-private"] = data["private"] - if data.get("archived"): - params["include-archived"] = data["archived"] - if data.get("guests"): - params["include-guests"] = data["guests"] - if data.get("deleted"): - params["include-deleted"] = data["deleted"] - return requests.get( - url, headers=headers, params=params, path_to_errors=self.path_to_errors - ) - - -class HipChatUsers(HipChatResourceMixin, ProviderResource): - """Return a list of HipChat users""" - - resource_name = "users" - - @property - def _schema(self): - user_schema = { - "guests": { - "type": "boolean", - "title": "Include active guest users in response. Otherwise, no guest users will be included", - }, - "deleted": {"type": "boolean", "title": "Include deleted users"}, - } - schema = copy.deepcopy(super()._schema) - schema["properties"].update(user_schema) - return schema - - def _get_resource(self, data: dict): - response, errors = self._get_resources(self.users_url, data) - if errors: - raise ResourceError( - errors=errors, - resource=self.resource_name, - provider=self.name, - data=data, - response=response, - ) - return response.json() - - -class HipChatRooms(HipChatResourceMixin, ProviderResource): - """Return a list of HipChat rooms""" - - resource_name = "rooms" - - @property - def _schema(self): - user_schema = { - "private": {"type": "boolean", "title": "Include private rooms"}, - "archived": {"type": "boolean", "title": "Include archive rooms"}, - } - schema = copy.deepcopy(super()._schema) - schema["properties"].update(user_schema) - return schema - - def _get_resource(self, data: dict): - response, errors = self._get_resources(self.rooms_url, data) - if errors: - raise ResourceError( - errors=errors, - resource=self.resource_name, - provider=self.name, - data=data, - response=response, - ) - return response.json() - - -class HipChat(HipChatMixin, Provider): - """Send HipChat notifications""" - - room_notification = "/{room}/notification" - user_message = "/{user}/message" - site_url = "https://www.hipchat.com/docs/apiv2" - - _resources = {"rooms": HipChatRooms(), "users": HipChatUsers()} - - __icon = { - "oneOf": [ - {"type": "string", "title": "The url where the icon is"}, - { - "type": "object", - "properties": { - "url": {"type": "string", "title": "The url where the icon is"}, - "url@2x": { - "type": "string", - "title": "The url for the icon in retina", - }, - }, - "required": ["url"], - "additionalProperties": False, - }, - ] - } - - __value = { - "type": "object", - "properties": { - "url": { - "type": "string", - "title": "Url to be opened when a user clicks on the label", - }, - "style": { - "type": "string", - "enum": [ - "lozenge-success", - "lozenge-error", - "lozenge-current", - "lozenge-complete", - "lozenge-moved", - "lozenge", - ], - "title": "AUI Integrations for now supporting only lozenges", - }, - "label": { - "type": "string", - "title": "The text representation of the value", - }, - "icon": __icon, - }, - } - - __attributes = { - "type": "array", - "title": "List of attributes to show below the card", - "items": { - "type": "object", - "properties": { - "value": __value, - "label": { - "type": "string", - "title": "Attribute label", - "minLength": 1, - "maxLength": 50, - }, - }, - "required": ["label", "value"], - "additionalProperties": False, - }, - } - - __activity = { - "type": "object", - "properties": { - "html": { - "type": "string", - "title": "Html for the activity to show in one line a summary of the action that happened", - }, - "icon": __icon, - }, - "required": ["html"], - "additionalProperties": False, - } - - __thumbnail = { - "type": "object", - "properties": { - "url": { - "type": "string", - "minLength": 1, - "maxLength": 250, - "title": "The thumbnail url", - }, - "width": {"type": "integer", "title": "The original width of the image"}, - "url@2x": { - "type": "string", - "minLength": 1, - "maxLength": 250, - "title": "The thumbnail url in retina", - }, - "height": {"type": "integer", "title": "The original height of the image"}, - }, - "required": ["url"], - "additionalProperties": False, - } - - __format = { - "type": "string", - "enum": ["text", "html"], - "title": "Determines how the message is treated by our server and rendered inside HipChat " - "applications", - } - - __description = { - "oneOf": [ - {"type": "string"}, - { - "type": "object", - "properties": { - "value": {"type": "string", "minLength": 1, "maxLength": 1000}, - "format": __format, - }, - "required": ["value", "format"], - "additionalProperties": False, - }, - ] - } - - __card = { - "type": "object", - "properties": { - "style": { - "type": "string", - "enum": ["file", "image", "application", "link", "media"], - "title": "Type of the card", - }, - "description": __description, - "format": { - "type": "string", - "enum": ["compact", "medium"], - "title": "Application cards can be compact (1 to 2 lines) or medium (1 to 5 lines)", - }, - "url": {"type": "string", "title": "The url where the card will open"}, - "title": { - "type": "string", - "minLength": 1, - "maxLength": 500, - "title": "The title of the card", - }, - "thumbnail": __thumbnail, - "activity": __activity, - "attributes": __attributes, - }, - "required": ["style", "title"], - "additionalProperties": False, - } - - _required = { - "allOf": [ - {"required": ["message", "id", "token"]}, - { - "oneOf": [{"required": ["room"]}, {"required": ["user"]}], - "error_oneOf": "Only one of 'room' or 'user' is allowed", - }, - { - "oneOf": [{"required": ["group"]}, {"required": ["team_server"]}], - "error_oneOf": "Only one 'group' or 'team_server' is allowed", - }, - ] - } - _schema = { - "type": "object", - "properties": { - "room": { - "type": "string", - "title": "The id or url encoded name of the room", - "maxLength": 100, - "minLength": 1, - }, - "user": { - "type": "string", - "title": "The id, email address, or mention name (beginning with an '@') " - "of the user to send a message to.", - }, - "message": { - "type": "string", - "title": "The message body", - "maxLength": 10_000, - "minLength": 1, - }, - "token": {"type": "string", "title": "User token"}, - "notify": { - "type": "boolean", - "title": "Whether this message should trigger a user notification (change the tab color," - " play a sound, notify mobile phones, etc). Each recipient's notification preferences " - "are taken into account.", - }, - "message_format": { - "type": "string", - "enum": ["text", "html"], - "title": "Determines how the message is treated by our server and rendered inside HipChat " - "applications", - }, - "from": { - "type": "string", - "title": "A label to be shown in addition to the sender's name", - }, - "color": { - "type": "string", - "enum": ["yellow", "green", "red", "purple", "gray", "random"], - "title": "Background color for message", - }, - "attach_to": { - "type": "string", - "title": "The message id to to attach this notification to", - }, - "card": __card, - "id": { - "type": "string", - "title": "An id that will help HipChat recognise the same card when it is sent multiple times", - }, - "icon": __icon, - "team_server": { - "type": "string", - "title": "An alternate team server. Example: 'https://hipchat.corp-domain.com'", - }, - "group": {"type": "string", "title": "HipChat group name"}, - }, - "additionalProperties": False, - } - - def _prepare_data(self, data: dict) -> dict: - if data.get("team_server"): - base_url = data["team_server"] - else: - base_url = self.base_url.format(group=data.pop("group")) - if data.get("room"): - url = ( - base_url - + self.rooms_url - + self.room_notification.format(room=data.pop("room")) - ) - else: - url = ( - base_url - + self.users_url - + self.user_message.format(user=data.pop("user")) - ) - data["url"] = url - return data - - def _send_notification(self, data: dict) -> Response: - url = data.pop("url") - headers = self._get_headers(data.pop("token")) - response, errors = requests.post( - url, json=data, headers=headers, path_to_errors=self.path_to_errors - ) - return self.create_response(data, response, errors) diff --git a/tests/providers/test_hipchat.py b/tests/providers/test_hipchat.py deleted file mode 100644 index 06463808..00000000 --- a/tests/providers/test_hipchat.py +++ /dev/null @@ -1,175 +0,0 @@ -import pytest - -from notifiers.exceptions import BadArguments -from notifiers.exceptions import NotificationError -from notifiers.exceptions import ResourceError - -provider = "hipchat" - - -class TestHipchat: - # No online test for hipchat since they're deprecated and denies new signups - - def test_metadata(self, provider): - assert provider.metadata == { - "base_url": "https://{group}.hipchat.com", - "name": "hipchat", - "site_url": "https://www.hipchat.com/docs/apiv2", - } - - @pytest.mark.parametrize( - "data, message", - [ - ( - { - "id": "foo", - "token": "bar", - "message": "boo", - "room": "bla", - "user": "gg", - }, - "Only one of 'room' or 'user' is allowed", - ), - ( - { - "id": "foo", - "token": "bar", - "message": "boo", - "room": "bla", - "team_server": "gg", - "group": "gg", - }, - "Only one 'group' or 'team_server' is allowed", - ), - ], - ) - def test_missing_required(self, data, message, provider): - data["env_prefix"] = "test" - with pytest.raises(BadArguments) as e: - provider.notify(**data) - assert message in e.value.message - - def test_bad_request(self, provider): - data = { - "token": "foo", - "room": "baz", - "message": "bar", - "id": "bla", - "group": "nada", - } - with pytest.raises(NotificationError) as e: - provider.notify(**data, raise_on_errors=True) - assert "Failed to establish a new connection" in e.value.message - - def test_hipchat_resources(self, provider): - assert provider.resources - assert len(provider.resources) == 2 - for resource in provider.resources: - assert getattr(provider, resource) - - -class TestHipChatRooms: - resource = "rooms" - - def test_hipchat_rooms_attribs(self, resource): - assert resource.schema == { - "type": "object", - "properties": { - "token": {"type": "string", "title": "User token"}, - "start": {"type": "integer", "title": "Start index"}, - "max_results": {"type": "integer", "title": "Max results in reply"}, - "group": {"type": "string", "title": "Hipchat group name"}, - "team_server": {"type": "string", "title": "Hipchat team server"}, - "private": {"type": "boolean", "title": "Include private rooms"}, - "archived": {"type": "boolean", "title": "Include archive rooms"}, - }, - "additionalProperties": False, - "allOf": [ - {"required": ["token"]}, - { - "oneOf": [{"required": ["group"]}, {"required": ["team_server"]}], - "error_oneOf": "Only one 'group' or 'team_server' is allowed", - }, - ], - } - - assert resource.required == { - "allOf": [ - {"required": ["token"]}, - { - "oneOf": [{"required": ["group"]}, {"required": ["team_server"]}], - "error_oneOf": "Only one 'group' or 'team_server' is allowed", - }, - ] - } - assert resource.name == provider - - def test_hipchat_rooms_negative(self, resource): - with pytest.raises(BadArguments): - resource(env_prefix="foo") - - def test_hipchat_rooms_negative_2(self, resource): - with pytest.raises(ResourceError) as e: - resource(token="foo", group="bat") - - assert "Failed to establish a new connection" in e.value.errors[0] - - -class TestHipChatUsers: - resource = "users" - - def test_hipchat_users_attribs(self, resource): - assert resource.schema == { - "type": "object", - "properties": { - "token": {"type": "string", "title": "User token"}, - "start": {"type": "integer", "title": "Start index"}, - "max_results": {"type": "integer", "title": "Max results in reply"}, - "group": {"type": "string", "title": "Hipchat group name"}, - "team_server": {"type": "string", "title": "Hipchat team server"}, - "guests": { - "type": "boolean", - "title": "Include active guest users in response. Otherwise, no guest users will be included", - }, - "deleted": {"type": "boolean", "title": "Include deleted users"}, - }, - "additionalProperties": False, - "allOf": [ - {"required": ["token"]}, - { - "oneOf": [{"required": ["group"]}, {"required": ["team_server"]}], - "error_oneOf": "Only one 'group' or 'team_server' is allowed", - }, - ], - } - - assert resource.required == { - "allOf": [ - {"required": ["token"]}, - { - "oneOf": [{"required": ["group"]}, {"required": ["team_server"]}], - "error_oneOf": "Only one 'group' or 'team_server' is allowed", - }, - ] - } - assert resource.name == provider - - def test_hipchat_users_negative(self, resource): - with pytest.raises(BadArguments): - resource(env_prefix="foo") - - -class TestHipchatCLI: - """Test hipchat specific CLI""" - - def test_hipchat_rooms_negative(self, cli_runner): - cmd = "hipchat rooms --token bad_token".split() - result = cli_runner(cmd) - assert result.exit_code - assert not result.output - - def test_hipchat_users_negative(self, cli_runner): - cmd = "hipchat users --token bad_token".split() - result = cli_runner(cmd) - assert result.exit_code - assert not result.output From 1cb4f352823e47f4a0028efae04767e4bea017cb Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 27 Feb 2020 13:08:42 +0200 Subject: [PATCH 016/137] removed hipchat from docs --- source/api/providers.rst | 4 - source/providers/hipchat.rst | 269 ----------------------------------- 2 files changed, 273 deletions(-) delete mode 100644 source/providers/hipchat.rst diff --git a/source/api/providers.rst b/source/api/providers.rst index 21dba71c..4140438d 100644 --- a/source/api/providers.rst +++ b/source/api/providers.rst @@ -14,10 +14,6 @@ API documentation for the different providers. :members: :undoc-members: -.. automodule:: notifiers.providers.hipchat - :members: - :undoc-members: - .. automodule:: notifiers.providers.join :members: :undoc-members: diff --git a/source/providers/hipchat.rst b/source/providers/hipchat.rst deleted file mode 100644 index 717a7509..00000000 --- a/source/providers/hipchat.rst +++ /dev/null @@ -1,269 +0,0 @@ -Hipchat -------- -Send notification to `Hipchat `_ rooms - -Simple example: - -.. code-block:: python - - >>> from notifiers import get_notifier - >>> hipchat = get_notifier('hipchat') - >>> hipchat.notify(token='SECRET', group='foo', message='hi!', room=1234) - -Hipchat requires using either a ``group`` or a ``team_server`` key word (for private instances) - -You can view the users you can send to via the ``users`` resource: - -.. code-block:: python - - >>> hipchat.users(token='SECRET', group='foo') - {'items': [{'id': 1, 'links': {'self': '...'}, 'mention_name': '...', 'name': '...', 'version': 'E4GX9340'}, ...]} - -You can view the rooms you can send to via the ``rooms`` resource: - -.. code-block:: python - - >>> hipchat.rooms(token='SECRET', group='foo') - {'items': [{'id': 9, 'is_archived': False, ... }] - - -Full schema: - -.. code-block:: yaml - - additionalProperties: false - allOf: - - required: - - message - - id - - token - - error_oneOf: Only one of 'room' or 'user' is allowed - oneOf: - - required: - - room - - required: - - user - - error_oneOf: Only one 'group' or 'team_server' is allowed - oneOf: - - required: - - group - - required: - - team_server - properties: - attach_to: - title: The message id to to attach this notification to - type: string - card: - additionalProperties: false - properties: - activity: - additionalProperties: false - properties: - html: - title: Html for the activity to show in one line a summary of the action - that happened - type: string - icon: - oneOf: - - title: The url where the icon is - type: string - - additionalProperties: false - properties: - url: - title: The url where the icon is - type: string - url@2x: - title: The url for the icon in retina - type: string - required: - - url - type: object - required: - - html - type: object - attributes: - items: - additionalProperties: false - properties: - label: - maxLength: 50 - minLength: 1 - title: Attribute label - type: string - value: - properties: - icon: - oneOf: - - title: The url where the icon is - type: string - - additionalProperties: false - properties: - url: - title: The url where the icon is - type: string - url@2x: - title: The url for the icon in retina - type: string - required: - - url - type: object - label: - title: The text representation of the value - type: string - style: - enum: - - lozenge-success - - lozenge-error - - lozenge-current - - lozenge-complete - - lozenge-moved - - lozenge - title: AUI Integrations for now supporting only lozenges - type: string - url: - title: Url to be opened when a user clicks on the label - type: string - type: object - required: - - label - - value - type: object - title: List of attributes to show below the card - type: array - description: - oneOf: - - type: string - - additionalProperties: false - properties: - format: - enum: - - text - - html - title: Determines how the message is treated by our server and rendered - inside HipChat applications - type: string - value: - maxLength: 1000 - minLength: 1 - type: string - required: - - value - - format - type: object - format: - enum: - - compact - - medium - title: Application cards can be compact (1 to 2 lines) or medium (1 to 5 lines) - type: string - style: - enum: - - file - - image - - application - - link - - media - title: Type of the card - type: string - thumbnail: - additionalProperties: false - properties: - height: - title: The original height of the image - type: integer - url: - maxLength: 250 - minLength: 1 - title: The thumbnail url - type: string - url@2x: - maxLength: 250 - minLength: 1 - title: The thumbnail url in retina - type: string - width: - title: The original width of the image - type: integer - required: - - url - type: object - title: - maxLength: 500 - minLength: 1 - title: The title of the card - type: string - url: - title: The url where the card will open - type: string - required: - - style - - title - type: object - color: - enum: - - yellow - - green - - red - - purple - - gray - - random - title: Background color for message - type: string - from: - title: A label to be shown in addition to the sender's name - type: string - group: - title: HipChat group name - type: string - icon: - oneOf: - - title: The url where the icon is - type: string - - additionalProperties: false - properties: - url: - title: The url where the icon is - type: string - url@2x: - title: The url for the icon in retina - type: string - required: - - url - type: object - id: - title: An id that will help HipChat recognise the same card when it is sent multiple - times - type: string - message: - maxLength: 10000 - minLength: 1 - title: The message body - type: string - message_format: - enum: - - text - - html - title: Determines how the message is treated by our server and rendered inside - HipChat applications - type: string - notify: - title: Whether this message should trigger a user notification (change the tab - color, play a sound, notify mobile phones, etc). Each recipient's notification - preferences are taken into account. - type: boolean - room: - maxLength: 100 - minLength: 1 - title: The id or url encoded name of the room - type: string - team_server: - title: 'An alternate team server. Example: ''https://hipchat.corp-domain.com''' - type: string - token: - title: User token - type: string - user: - title: The id, email address, or mention name (beginning with an '@') of the user - to send a message to. - type: string - type: object \ No newline at end of file From dc6cfdf33f5cee933acac43e13d9964ef6076144 Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 27 Feb 2020 13:09:34 +0200 Subject: [PATCH 017/137] typos --- source/about.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/source/about.rst b/source/about.rst index 608f0868..04e925b7 100644 --- a/source/about.rst +++ b/source/about.rst @@ -27,7 +27,7 @@ which is shared among all notifiers and replaced internally as needed. Snake Case ~~~~~~~~~~ -While the majority of providers already expect lower case and speicfically snake cased properties in their request, some do not. +While the majority of providers already expect lower case and specifically snake cased properties in their request, some do not. Notifiers normalizes this by making all request properties snake case and converting to relevant usage behind the scenes. Reserved words issue @@ -44,7 +44,7 @@ The first is to construct data via a dict and unpack it into the :meth:`~notifie ... } >>> provider.notify(**data) -The other is to use an alternate key word, which would always be the reservred key word followed by an underscore: +The other is to use an alternate key word, which would always be the reserved key word followed by an underscore: .. code:: python From a24befc0bd169be275548ae7af8475fe1ec33b08 Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 27 Feb 2020 13:10:11 +0200 Subject: [PATCH 018/137] removed hipchat from README.MD --- README.MD | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.MD b/README.MD index 0559c2e5..55eb9378 100644 --- a/README.MD +++ b/README.MD @@ -11,7 +11,7 @@ Got an app or service and you want to enable your users to use notifications wit # Supported providers -[Pushover](https://pushover.net/), [SimplePush](https://simplepush.io/), [Slack](https://api.slack.com/), [Gmail](https://www.google.com/gmail/about/), Email (SMTP), [Telegram](https://telegram.org/), [Gitter](https://gitter.im), [Pushbullet](https://www.pushbullet.com), [Join](https://joaoapps.com/join/), [Hipchat](https://www.hipchat.com/docs/apiv2), [Zulip](https://zulipchat.com/), [Twilio](https://www.twilio.com/), [Pagerduty](https://www.pagerduty.com), [Mailgun](https://www.mailgun.com/), [PopcornNotify](https://popcornnotify.com), [StatusPage.io](https://statuspage.io) +[Pushover](https://pushover.net/), [SimplePush](https://simplepush.io/), [Slack](https://api.slack.com/), [Gmail](https://www.google.com/gmail/about/), Email (SMTP), [Telegram](https://telegram.org/), [Gitter](https://gitter.im), [Pushbullet](https://www.pushbullet.com), [Join](https://joaoapps.com/join/), [Zulip](https://zulipchat.com/), [Twilio](https://www.twilio.com/), [Pagerduty](https://www.pagerduty.com), [Mailgun](https://www.mailgun.com/), [PopcornNotify](https://popcornnotify.com), [StatusPage.io](https://statuspage.io) # Advantages From bf58cb10303378498b354b50fbcf01368149b594 Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 27 Feb 2020 16:06:39 +0200 Subject: [PATCH 019/137] added join options --- notifiers/providers/join.py | 60 +++++++++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/notifiers/providers/join.py b/notifiers/providers/join.py index 3021383d..38acc602 100644 --- a/notifiers/providers/join.py +++ b/notifiers/providers/join.py @@ -1,4 +1,6 @@ import json +from enum import Enum +from typing import Union from urllib.parse import urljoin import requests @@ -15,11 +17,21 @@ from ..models.response import Response +class JoinGroup(Enum): + all_ = "group.all" + android = "group.android" + windows_10 = "group.windows10" + phone = "group.phone" + tablet = "group.tablet" + pc = "group.pc" + + class JoinBaseSchema(SchemaModel): api_key: str = Field(..., description="User API key", alias="apikey") class Config: extra = Extra.forbid + json_encoders = {JoinGroup: lambda v: v.value} class JoinSchema(JoinBaseSchema): @@ -29,8 +41,8 @@ class JoinSchema(JoinBaseSchema): description="Usually used as a Tasker or EventGhost command." " Can also be used with URLs and Files to add a description for those elements", ) - device_id: str = Field( - "group.all", + device_id: Union[str, JoinGroup] = Field( + JoinGroup.all_, description="The device ID or group ID of the device you want to send the message to", alias="deviceId", ) @@ -57,9 +69,25 @@ class JoinSchema(JoinBaseSchema): mms_file: HttpUrl = Field( None, description="A publicly accessible MMS file URL", alias="mmsfile" ) + mms_subject: str = Field( + None, + description="Subject for the message. This will make the sent message be an MMS instead of an SMS", + alias="mmssubject", + ) + mms_urgent: bool = Field( + None, + description="Set to 1 if this is an urgent MMS. This will make the sent message be an MMS instead of an SMS", + alias="mmsurgent", + ) wallpaper: HttpUrl = Field( None, description="A publicly accessible URL of an image file" ) + lock_wallpaper: HttpUrl = Field( + None, + description="A publicly accessible URL of an image file." + " Will set the lockscreen wallpaper on the receiving device if the device has Android 7 or above", + alias="lockWallpaper", + ) icon: HttpUrl = Field(None, description="Notification's icon URL") small_icon: HttpUrl = Field( None, description="Status Bar Icon URL", alias="smallicon" @@ -71,6 +99,12 @@ class JoinSchema(JoinBaseSchema): sms_text: str = Field( None, description="Some text to send in an SMS", alias="smstext" ) + sms_contact_name: str = Field( + None, + description="Alternatively to the smsnumber you can specify this and Join will send the SMS" + " to the first number that matches the name", + alias="smscontactname", + ) call_number: str = Field(None, description="Number to call to", alias="callnumber") interruption_filter: int = Field( None, @@ -100,6 +134,25 @@ class JoinSchema(JoinBaseSchema): group: str = Field( None, description="Allows you to join notifications in different groups" ) + say: str = Field(None, description="Say some text out loud") + language: str = Field(None, description="The language to use for the say text") + app: str = Field( + None, description="App name of the app you want to open on the remote device" + ) + app_package: str = Field( + None, + description="Package name of the app you want to open on the remote device", + alias="appPackage", + ) + dismiss_on_touch: bool = Field( + None, + description="Set to true to make the notification go away when you touch it", + alias="dismissOnTouch", + ) + + @validator("mms_urgent", pre=True) + def mms_urgent(cls, v): + return int(v) @root_validator(pre=True) def sms_validation(cls, values): @@ -130,8 +183,9 @@ class JoinMixin: def _join_request(url: str, data: JoinBaseSchema) -> tuple: # Can 't use generic requests util since API doesn't always return error status errors = None + params = json.loads(data.json(by_alias=True, exclude_none=True)) try: - response = requests.get(url, params=data.dict(by_alias=True)) + response = requests.get(url, params=params) response.raise_for_status() rsp = response.json() if not rsp["success"]: From 9d432ba0db6c300c86d7e26e6ef9e7dd0f14fd2c Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 27 Feb 2020 16:28:16 +0200 Subject: [PATCH 020/137] converted pagerduty --- notifiers/providers/pagerduty.py | 231 +++++++++++++++---------------- 1 file changed, 109 insertions(+), 122 deletions(-) diff --git a/notifiers/providers/pagerduty.py b/notifiers/providers/pagerduty.py index e3732e41..6cc69b5c 100644 --- a/notifiers/providers/pagerduty.py +++ b/notifiers/providers/pagerduty.py @@ -1,8 +1,113 @@ +import json +from datetime import datetime +from enum import Enum +from typing import List + +from pydantic import constr +from pydantic import Field +from pydantic import HttpUrl +from pydantic import validator + from ..models.provider import Provider +from ..models.provider import SchemaModel from ..models.response import Response from ..utils import requests +class PagerDutyLink(SchemaModel): + href: HttpUrl = Field(..., description="URL of the link to be attached.") + text: str = Field( + ..., + description="Plain text that describes the purpose of the link, and can be used as the link's text", + ) + + +class PagerDutyImage(SchemaModel): + src: HttpUrl = Field( + ..., + description="The source of the image being attached to the incident. This image must be served via HTTPS.", + ) + href: HttpUrl = Field( + None, description="Optional URL; makes the image a clickable link." + ) + alt: str = Field(None, description="Optional alternative text for the image.") + + +class PagerDutyPayloadSeverity(Enum): + info = "info" + warning = "warning" + error = "error" + critical = "critical" + + +class PagerDutyEventAction(Enum): + trigger = "trigger" + acknowledge = "acknowledge" + resolve = "resolve" + + +class PagerDutyPayload(SchemaModel): + message: constr(max_length=1024) = Field( + ..., + description="A brief text summary of the event," + " used to generate the summaries/titles of any associated alerts.", + alias="summary", + ) + source: str = Field( + ..., + description="The unique location of the affected system, preferably a hostname or FQDN", + ) + severity: PagerDutyPayloadSeverity = Field( + ..., + description="The perceived severity of the status the event is describing with respect to the affected system", + ) + timestamp: datetime = Field( + None, + description="The time at which the emitting tool detected or generated the event", + ) + component: str = Field( + None, + description="Component of the source machine that is responsible for the event, for example mysql or eth0", + ) + group: str = Field( + None, + description="Logical grouping of components of a service, for example app-stack", + ) + class_: str = Field( + None, + description="The class/type of the event, for example ping failure or cpu load", + alias="class", + ) + custom_details: dict = Field( + None, description="Additional details about the event and affected system" + ) + + @validator("timestamp") + def to_timestamp(cls, v: datetime): + return v.timestamp() + + class Config: + json_encoders = {PagerDutyPayloadSeverity: lambda v: v.value} + allow_population_by_field_name = True + + +class PagerDutySchema(SchemaModel): + routing_key: constr(min_length=32, max_length=32) = Field( + ..., + description="This is the 32 character Integration Key for an integration on a service or on a global ruleset", + ) + event_action: PagerDutyEventAction = Field(..., description="The type of event") + dedup_key: constr(max_length=255) = Field( + None, description="Deduplication key for correlating triggers and resolves" + ) + payload: PagerDutyPayload + images: List[PagerDutyImage] = Field(None, description="List of images to include") + links: List[PagerDutyLink] = Field(None, description="List of links to include") + + class Config: + json_encoders = {PagerDutyEventAction: lambda v: v.value} + + class PagerDuty(Provider): """Send PagerDuty Events""" @@ -11,128 +116,10 @@ class PagerDuty(Provider): site_url = "https://v2.developer.pagerduty.com/" path_to_errors = ("errors",) - __payload_attributes = [ - "message", - "source", - "severity", - "timestamp", - "component", - "group", - "class", - "custom_details", - ] - - __images = { - "type": "array", - "items": { - "type": "object", - "properties": { - "src": { - "type": "string", - "title": "The source of the image being attached to the incident. " - "This image must be served via HTTPS.", - }, - "href": { - "type": "string", - "title": "Optional URL; makes the image a clickable link", - }, - "alt": { - "type": "string", - "title": "Optional alternative text for the image", - }, - }, - "required": ["src"], - "additionalProperties": False, - }, - } - - __links = { - "type": "array", - "items": { - "type": "object", - "properties": { - "href": {"type": "string", "title": "URL of the link to be attached"}, - "text": { - "type": "string", - "title": "Plain text that describes the purpose of the link, and can be used as the link's text", - }, - }, - "required": ["href", "text"], - "additionalProperties": False, - }, - } - - _required = { - "required": ["routing_key", "event_action", "source", "severity", "message"] - } - - _schema = { - "type": "object", - "properties": { - "message": { - "type": "string", - "title": "A brief text summary of the event, used to generate the summaries/titles of any " - "associated alerts", - }, - "routing_key": { - "type": "string", - "title": 'The GUID of one of your Events API V2 integrations. This is the "Integration Key" listed on' - " the Events API V2 integration's detail page", - }, - "event_action": { - "type": "string", - "enum": ["trigger", "acknowledge", "resolve"], - "title": "The type of event", - }, - "dedup_key": { - "type": "string", - "title": "Deduplication key for correlating triggers and resolves", - "maxLength": 255, - }, - "source": { - "type": "string", - "title": "The unique location of the affected system, preferably a hostname or FQDN", - }, - "severity": { - "type": "string", - "enum": ["critical", "error", "warning", "info"], - "title": "The perceived severity of the status the event is describing with respect to the " - "affected system", - }, - "timestamp": { - "type": "string", - "format": "iso8601", - "title": "The time at which the emitting tool detected or generated the event in ISO 8601", - }, - "component": { - "type": "string", - "title": "Component of the source machine that is responsible for the event", - }, - "group": { - "type": "string", - "title": "Logical grouping of components of a service", - }, - "class": {"type": "string", "title": "The class/type of the event"}, - "custom_details": { - "type": "object", - "title": "Additional details about the event and affected system", - }, - "images": __images, - "links": __links, - }, - } - - def _prepare_data(self, data: dict) -> dict: - payload = { - attribute: data.pop(attribute) - for attribute in self.__payload_attributes - if data.get(attribute) - } - payload["summary"] = payload.pop("message") - data["payload"] = payload - return data - - def _send_notification(self, data: dict) -> Response: + schema_model = PagerDutySchema + + def _send_notification(self, data: PagerDutySchema) -> Response: + data = json.loads(data.json(exclude_none=True, by_alias=True)) url = self.base_url response, errors = requests.post( url, json=data, path_to_errors=self.path_to_errors From 36ce3063e066b4229bef5a516961164fbb804f96 Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 27 Feb 2020 16:31:22 +0200 Subject: [PATCH 021/137] added to_dict method --- notifiers/models/provider.py | 7 +++++++ notifiers/providers/pagerduty.py | 4 +--- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/notifiers/models/provider.py b/notifiers/models/provider.py index b3793970..fc3453bf 100644 --- a/notifiers/models/provider.py +++ b/notifiers/models/provider.py @@ -1,3 +1,4 @@ +import json from abc import ABC from abc import abstractmethod from typing import Any @@ -38,6 +39,12 @@ def to_comma_separated(values: Union[Any, List[Any]]) -> str: def single_or_list(type_): return Union[List[type_], type_] + def to_dict(self, exclude_none: bool = True, by_alias: bool = True) -> dict: + """A helper method to a very common dict builder. + Round tripping to json and back to dict is needed since the model can contain special object that need + to be transformed to json first (like enums)""" + return json.loads(self.json(exclude_none=exclude_none, by_alias=by_alias)) + class Config: allow_population_by_field_name = True extra = Extra.forbid diff --git a/notifiers/providers/pagerduty.py b/notifiers/providers/pagerduty.py index 6cc69b5c..56cd323b 100644 --- a/notifiers/providers/pagerduty.py +++ b/notifiers/providers/pagerduty.py @@ -1,4 +1,3 @@ -import json from datetime import datetime from enum import Enum from typing import List @@ -119,9 +118,8 @@ class PagerDuty(Provider): schema_model = PagerDutySchema def _send_notification(self, data: PagerDutySchema) -> Response: - data = json.loads(data.json(exclude_none=True, by_alias=True)) url = self.base_url response, errors = requests.post( - url, json=data, path_to_errors=self.path_to_errors + url, json=data.to_dict(), path_to_errors=self.path_to_errors ) return self.create_response(data, response, errors) From d6f5b3edd218343eb9f6b33d1b4fc18435d349ef Mon Sep 17 00:00:00 2001 From: liiight Date: Fri, 28 Feb 2020 22:51:08 +0200 Subject: [PATCH 022/137] converted popcornnotify --- notifiers/providers/popcornnotify.py | 56 +++++++++++++--------------- 1 file changed, 25 insertions(+), 31 deletions(-) diff --git a/notifiers/providers/popcornnotify.py b/notifiers/providers/popcornnotify.py index f392dd02..98c882f8 100644 --- a/notifiers/providers/popcornnotify.py +++ b/notifiers/providers/popcornnotify.py @@ -1,8 +1,27 @@ +from pydantic import Field +from pydantic import validator + from ..models.provider import Provider +from ..models.provider import SchemaModel from ..models.response import Response from ..utils import requests -from ..utils.schema.helpers import list_to_commas -from ..utils.schema.helpers import one_or_more + + +class PopcornNotifySchema(SchemaModel): + message: str = Field(..., description="The message to send") + api_key: str = Field(..., description="The API key") + subject: str = Field( + None, + description="The subject of the email. It will not be included in text messages", + ) + recipients: SchemaModel.single_or_list(str) = Field( + ..., + description="The recipient email address or phone number.Or an array of email addresses and phone numbers", + ) + + @validator("recipients") + def recipient_to_comma(cls, v): + return cls.to_comma_separated(v) class PopcornNotify(Provider): @@ -13,35 +32,10 @@ class PopcornNotify(Provider): name = "popcornnotify" path_to_errors = ("error",) - _required = {"required": ["message", "api_key", "recipients"]} - - _schema = { - "type": "object", - "properties": { - "message": {"type": "string", "title": "The message to send"}, - "api_key": {"type": "string", "title": "The API key"}, - "recipients": one_or_more( - { - "type": "string", - "format": "email", - "title": "The recipient email address or phone number." - " Or an array of email addresses and phone numbers", - } - ), - "subject": { - "type": "string", - "title": "The subject of the email. It will not be included in text messages.", - }, - }, - } - - def _prepare_data(self, data: dict) -> dict: - if isinstance(data["recipients"], str): - data["recipients"] = [data["recipients"]] - data["recipients"] = list_to_commas(data["recipients"]) - return data - - def _send_notification(self, data: dict) -> Response: + schema_model = PopcornNotifySchema + + def _send_notification(self, data: PopcornNotifySchema) -> Response: + data = data.to_dict() response, errors = requests.post( url=self.base_url, json=data, path_to_errors=self.path_to_errors ) From 97c205bcdad624934925198a97a39ff6b0f0d517 Mon Sep 17 00:00:00 2001 From: liiight Date: Fri, 28 Feb 2020 23:11:04 +0200 Subject: [PATCH 023/137] converted pushbullet --- notifiers/providers/pushbullet.py | 71 +++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/notifiers/providers/pushbullet.py b/notifiers/providers/pushbullet.py index 3af385e6..57d8ac32 100644 --- a/notifiers/providers/pushbullet.py +++ b/notifiers/providers/pushbullet.py @@ -1,10 +1,81 @@ +from enum import Enum + +from pydantic import EmailStr +from pydantic import Field +from pydantic import FilePath +from pydantic import HttpUrl +from pydantic import root_validator + from ..exceptions import ResourceError from ..models.provider import Provider from ..models.provider import ProviderResource +from ..models.provider import SchemaModel from ..models.response import Response from ..utils import requests +class PushbulletType(Enum): + note = "note" + file = "file" + link = "link" + + +class PushbulletSchema(SchemaModel): + type: PushbulletType = Field(PushbulletType.note, description="Type of the push") + body: str = Field( + ..., + description="Body of the push, used for all types of pushes", + alias="message", + ) + token: str = Field(..., description="API access token") + title: str = Field( + None, description="Title of the push, used for all types of pushes" + ) + url: HttpUrl = Field(None, description='URL field, used for type="link" pushes') + file: FilePath = Field(None, description="A path to a file to upload") + source_device_iden: str = Field( + None, + description='Device iden of the sending device. Optional. Example: "ujpah72o0sjAoRtnM0jc"', + ) + device_iden: str = Field( + None, + description="Device iden of the target device, if sending to a single device. " + 'Appears as target_device_iden on the push. Example: "ujpah72o0sjAoRtnM0jc"', + ) + client_iden: str = Field( + None, + description="Client iden of the target client, sends a push to all users who have granted access" + ' to this client. The current user must own this client. Example: "ujpah72o0sjAoRtnM0jc"', + ) + channel_tag: str = Field( + None, + description="Channel tag of the target channel, sends a push to all people who are subscribed to this channel. " + "The current user must own this channel.", + ) + email: EmailStr = Field( + None, + description="Email address to send the push to. If there is a pushbullet user with this address, " + 'they get a push, otherwise they get an email. Example: "elon@teslamotors.com"', + ) + guid: str = Field( + None, + description="Unique identifier set by the client, used to identify a push in case you receive it from " + "/v2/everything before the call to /v2/pushes has completed. This should be a unique value." + " Pushes with guid set are mostly idempotent, meaning that sending another push with the same" + " guid is unlikely to create another push (it will return the previously created push). " + 'Example: "993aaa48567d91068e96c75a74644159"', + ) + + @root_validator(pre=True) + def validate_types(cls, values): + type = values["type"] + if type is PushbulletType.link and not values.get("url"): + raise ValueError("'url' must be passed when push type is link") + elif type is PushbulletType.file and not values.get("file"): + raise ValueError("'file' must be passed when push type is file") + return values + + class PushbulletMixin: """Shared attributes between :class:`PushbulletDevices` and :class:`Pushbullet`""" From 070fb8b37b81e8ab45aa35af2db169a2cc10a1ea Mon Sep 17 00:00:00 2001 From: liiight Date: Fri, 28 Feb 2020 23:40:50 +0200 Subject: [PATCH 024/137] converted pushbullet --- notifiers/providers/__init__.py | 2 +- notifiers/providers/join.py | 2 +- notifiers/providers/pushbullet.py | 119 ++++++++++++------------------ 3 files changed, 48 insertions(+), 75 deletions(-) diff --git a/notifiers/providers/__init__.py b/notifiers/providers/__init__.py index d3d134bf..ef8defc0 100644 --- a/notifiers/providers/__init__.py +++ b/notifiers/providers/__init__.py @@ -23,7 +23,7 @@ "gmail": gmail.Gmail, # "telegram": telegram.Telegram, "gitter": gitter.Gitter, - # "pushbullet": pushbullet.Pushbullet, + "pushbullet": pushbullet.Pushbullet, # "join": join.Join, # "zulip": zulip.Zulip, # "twilio": twilio.Twilio, diff --git a/notifiers/providers/join.py b/notifiers/providers/join.py index 38acc602..f82f9774 100644 --- a/notifiers/providers/join.py +++ b/notifiers/providers/join.py @@ -151,7 +151,7 @@ class JoinSchema(JoinBaseSchema): ) @validator("mms_urgent", pre=True) - def mms_urgent(cls, v): + def mms_urgent_format(cls, v): return int(v) @root_validator(pre=True) diff --git a/notifiers/providers/pushbullet.py b/notifiers/providers/pushbullet.py index 57d8ac32..1bc127a8 100644 --- a/notifiers/providers/pushbullet.py +++ b/notifiers/providers/pushbullet.py @@ -1,4 +1,6 @@ from enum import Enum +from functools import partial +from mimetypes import guess_type from pydantic import EmailStr from pydantic import Field @@ -22,10 +24,8 @@ class PushbulletType(Enum): class PushbulletSchema(SchemaModel): type: PushbulletType = Field(PushbulletType.note, description="Type of the push") - body: str = Field( - ..., - description="Body of the push, used for all types of pushes", - alias="message", + message: str = Field( + ..., description="Body of the push, used for all types of pushes", alias="body" ) token: str = Field(..., description="API access token") title: str = Field( @@ -66,7 +66,7 @@ class PushbulletSchema(SchemaModel): 'Example: "993aaa48567d91068e96c75a74644159"', ) - @root_validator(pre=True) + @root_validator() def validate_types(cls, values): type = values["type"] if type is PushbulletType.link and not values.get("url"): @@ -119,82 +119,55 @@ class Pushbullet(PushbulletMixin, Provider): """Send Pushbullet notifications""" base_url = "https://api.pushbullet.com/v2/pushes" + upload_request = "https://api.pushbullet.com/v2/upload-request" site_url = "https://www.pushbullet.com" - __type = { - "type": "string", - "title": 'Type of the push, one of "note" or "link"', - "enum": ["note", "link"], - } - _resources = {"devices": PushbulletDevices()} - _required = {"required": ["message", "token"]} - _schema = { - "type": "object", - "properties": { - "message": {"type": "string", "title": "Body of the push"}, - "token": {"type": "string", "title": "API access token"}, - "title": {"type": "string", "title": "Title of the push"}, - "type": __type, - "type_": __type, - "url": { - "type": "string", - "title": 'URL field, used for type="link" pushes', - }, - "source_device_iden": { - "type": "string", - "title": "Device iden of the sending device", - }, - "device_iden": { - "type": "string", - "title": "Device iden of the target device, if sending to a single device", - }, - "client_iden": { - "type": "string", - "title": "Client iden of the target client, sends a push to all users who have granted access to " - "this client. The current user must own this client", - }, - "channel_tag": { - "type": "string", - "title": "Channel tag of the target channel, sends a push to all people who are subscribed to " - "this channel. The current user must own this channel.", - }, - "email": { - "type": "string", - "format": "email", - "title": "Email address to send the push to. If there is a pushbullet user with this address," - " they get a push, otherwise they get an email", - }, - "guid": { - "type": "string", - "title": "Unique identifier set by the client, used to identify a push in case you receive it " - "from /v2/everything before the call to /v2/pushes has completed. This should be a unique" - " value. Pushes with guid set are mostly idempotent, meaning that sending another push " - "with the same guid is unlikely to create another push (it will return the previously" - " created push).", - }, - }, - "additionalProperties": False, - } + schema_model = PushbulletSchema - @property - def defaults(self) -> dict: - return {"type": "note"} - - def _prepare_data(self, data: dict) -> dict: - data["body"] = data.pop("message") + def _upload_file(self, file: FilePath, headers: dict) -> dict: + """Fetches an upload URL and upload the content of the file""" + data = {"file_name": file.name, "file_type": guess_type(str(file))} + response, errors = requests.post( + self.file_upload, + json=data, + headers=headers, + path_to_errors=self.path_to_errors, + ) + error = partial( + ResourceError, + errors=errors, + resource=self.resource_name, + provider=self.name, + data=data, + response=response, + ) + if errors: + raise error() + file_data = response.json() + files = requests.file_list_for_request( + [file], "file", mimetype=file_data["file_type"] + ) + response, errors = requests.post( + file_data.pop("upload_url"), + files=files, + headers=headers, + path_to_errors=self.path_to_errors, + ) + if errors: + raise error() - # Workaround since `type` is a reserved word - if data.get("type_"): - data["type"] = data.pop("type_") - return data + return file_data - def _send_notification(self, data: dict) -> Response: - headers = self._get_headers(data.pop("token")) + def _send_notification(self, data: PushbulletSchema) -> Response: + request_data = data.to_dict() + headers = self._get_headers(request_data.pop("token")) + if data.file: + request_data.update(self._upload_file(data.file, headers)) response, errors = requests.post( self.base_url, - json=data, + json=request_data, headers=headers, path_to_errors=self.path_to_errors, ) - return self.create_response(data, response, errors) + return self.create_response(request_data, response, errors) From a0df67e93250673222251b8bb20b973a936a0969 Mon Sep 17 00:00:00 2001 From: liiight Date: Fri, 28 Feb 2020 23:55:04 +0200 Subject: [PATCH 025/137] implemented pushbullet file upload --- notifiers/providers/pushbullet.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/notifiers/providers/pushbullet.py b/notifiers/providers/pushbullet.py index 1bc127a8..49436236 100644 --- a/notifiers/providers/pushbullet.py +++ b/notifiers/providers/pushbullet.py @@ -66,7 +66,7 @@ class PushbulletSchema(SchemaModel): 'Example: "993aaa48567d91068e96c75a74644159"', ) - @root_validator() + @root_validator(skip_on_failure=True) def validate_types(cls, values): type = values["type"] if type is PushbulletType.link and not values.get("url"): @@ -127,9 +127,9 @@ class Pushbullet(PushbulletMixin, Provider): def _upload_file(self, file: FilePath, headers: dict) -> dict: """Fetches an upload URL and upload the content of the file""" - data = {"file_name": file.name, "file_type": guess_type(str(file))} + data = {"file_name": file.name, "file_type": guess_type(str(file))[0]} response, errors = requests.post( - self.file_upload, + self.upload_request, json=data, headers=headers, path_to_errors=self.path_to_errors, @@ -137,7 +137,7 @@ def _upload_file(self, file: FilePath, headers: dict) -> dict: error = partial( ResourceError, errors=errors, - resource=self.resource_name, + resource="pushbullet_file_upload", provider=self.name, data=data, response=response, From 2db5cfd26ad1ff847e4606ca7c84126fa6b71641 Mon Sep 17 00:00:00 2001 From: liiight Date: Sat, 29 Feb 2020 00:06:05 +0200 Subject: [PATCH 026/137] minor tweak --- notifiers/providers/pushbullet.py | 2 +- notifiers/utils/requests.py | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/notifiers/providers/pushbullet.py b/notifiers/providers/pushbullet.py index 49436236..add49aaf 100644 --- a/notifiers/providers/pushbullet.py +++ b/notifiers/providers/pushbullet.py @@ -146,7 +146,7 @@ def _upload_file(self, file: FilePath, headers: dict) -> dict: raise error() file_data = response.json() files = requests.file_list_for_request( - [file], "file", mimetype=file_data["file_type"] + file, "file", mimetype=file_data["file_type"] ) response, errors = requests.post( file_data.pop("upload_url"), diff --git a/notifiers/utils/requests.py b/notifiers/utils/requests.py index 37c658a6..28cc5f26 100644 --- a/notifiers/utils/requests.py +++ b/notifiers/utils/requests.py @@ -2,6 +2,7 @@ import logging from pathlib import Path from typing import List +from typing import Union import requests @@ -82,19 +83,18 @@ def post(url: str, *args, **kwargs) -> tuple: def file_list_for_request( - list_of_paths: List[Path], key_name: str, mimetype: str = None + paths: Union[List[Path], Path], key_name: str, mimetype: str = None ) -> list: """ Convenience function to construct a list of files for multiple files upload by :mod:`requests` - :param list_of_paths: Lists of strings to include in files. Should be pre validated for correctness + :param paths: Lists of strings to include in files. Should be pre validated for correctness :param key_name: The key name to use for the file list in the request :param mimetype: If specified, will be included in the requests :return: List of open files ready to be used in a request """ + if not isinstance(paths, list): + paths = [paths] if mimetype: - return [ - (key_name, (file.name, file.read_bytes(), mimetype)) - for file in list_of_paths - ] - return [(key_name, (file.name, file.read_bytes())) for file in list_of_paths] + return [(key_name, (file.name, file.read_bytes(), mimetype)) for file in paths] + return [(key_name, (file.name, file.read_bytes())) for file in paths] From 78cbc2daa26acd75443e3235cc55b99c99233709 Mon Sep 17 00:00:00 2001 From: liiight Date: Sat, 29 Feb 2020 00:12:03 +0200 Subject: [PATCH 027/137] tweak --- notifiers/utils/requests.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notifiers/utils/requests.py b/notifiers/utils/requests.py index 28cc5f26..260e6721 100644 --- a/notifiers/utils/requests.py +++ b/notifiers/utils/requests.py @@ -14,7 +14,7 @@ class RequestsHelper: @classmethod def request( - self, + cls, url: str, method: str, raise_for_status: bool = True, From de3c2ee54f8c9241bbc4a510ba17ae9cd45b6d6d Mon Sep 17 00:00:00 2001 From: liiight Date: Sat, 29 Feb 2020 00:15:44 +0200 Subject: [PATCH 028/137] refactored pushbullet devices --- notifiers/providers/pushbullet.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/notifiers/providers/pushbullet.py b/notifiers/providers/pushbullet.py index add49aaf..f541c2ab 100644 --- a/notifiers/providers/pushbullet.py +++ b/notifiers/providers/pushbullet.py @@ -22,12 +22,15 @@ class PushbulletType(Enum): link = "link" -class PushbulletSchema(SchemaModel): +class PushbulletBaseSchema(SchemaModel): + token: str = Field(..., description="API access token") + + +class PushbulletSchema(PushbulletBaseSchema): type: PushbulletType = Field(PushbulletType.note, description="Type of the push") message: str = Field( ..., description="Body of the push, used for all types of pushes", alias="body" ) - token: str = Field(..., description="API access token") title: str = Field( None, description="Title of the push, used for all types of pushes" ) @@ -93,14 +96,10 @@ class PushbulletDevices(PushbulletMixin, ProviderResource): devices_url = "https://api.pushbullet.com/v2/devices" _required = {"required": ["token"]} - _schema = { - "type": "object", - "properties": {"token": {"type": "string", "title": "API access token"}}, - "additionalProperties": False, - } - - def _get_resource(self, data: dict) -> list: - headers = self._get_headers(data["token"]) + schema_model = PushbulletBaseSchema + + def _get_resource(self, data: PushbulletBaseSchema) -> list: + headers = self._get_headers(data.token) response, errors = requests.get( self.devices_url, headers=headers, path_to_errors=self.path_to_errors ) From bb9f5682a8e1857535792e12ba7a76440c78f3c4 Mon Sep 17 00:00:00 2001 From: liiight Date: Sat, 29 Feb 2020 01:42:12 +0200 Subject: [PATCH 029/137] converted pushover --- .pre-commit-config.yaml | 2 +- notifiers/providers/pushover.py | 252 +++++++++++++++++--------------- 2 files changed, 137 insertions(+), 117 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dc45e222..07c3eed8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -11,7 +11,7 @@ repos: args: ['--max-line-length=120'] additional_dependencies: ['flake8-mutable', 'flake8-comprehensions'] - repo: https://github.com/psf/black - rev: 19.3b0 + rev: 19.10b0 hooks: - id: black language_version: python3.6 diff --git a/notifiers/providers/pushover.py b/notifiers/providers/pushover.py index 2ef9fdce..efe5b217 100644 --- a/notifiers/providers/pushover.py +++ b/notifiers/providers/pushover.py @@ -1,10 +1,129 @@ +from datetime import datetime +from enum import Enum +from urllib.parse import urljoin + +from pydantic import conint +from pydantic import Field +from pydantic import FilePath +from pydantic import HttpUrl +from pydantic import root_validator +from pydantic import validator + from ..exceptions import ResourceError from ..models.provider import Provider from ..models.provider import ProviderResource +from ..models.provider import SchemaModel from ..models.response import Response from ..utils import requests -from ..utils.schema.helpers import list_to_commas -from ..utils.schema.helpers import one_or_more + + +class PushoverSound(Enum): + pushover = "pushover" + bike = "bike" + bugle = "bugle" + cash_register = "cashregister" + classical = "classical" + cosmic = "cosmic" + falling = "falling" + gamelan = "gamelan" + incoming = "incoming" + intermission = "intermission" + magic = "magic" + mechanical = "mechanical" + piano_bar = "pianobar" + siren = "siren" + space_alarm = "spacealarm" + tug_boat = "tugboat" + alien = "alien" + climb = "climb" + persistent = "persistent" + echo = "echo" + updown = "updown" + none = None + + +class PushoverBaseSchema(SchemaModel): + token: str = Field(..., description="Your application's API token ") + + +class PushoverSchema(PushoverBaseSchema): + user: PushoverBaseSchema.single_or_list(str) = Field( + ..., description="The user/group key (not e-mail address) of your user (or you)" + ) + message: str = Field(..., description="Your message") + attachment: FilePath = Field( + None, description="An image attachment to send with the message" + ) + device: PushoverBaseSchema.single_or_list(str) = Field( + None, + description="Your user's device name to send the message directly to that device," + " rather than all of the user's devices", + ) + title: str = Field( + None, description="Your message's title, otherwise your app's name is used" + ) + url: HttpUrl = Field( + None, description="A supplementary URL to show with your message" + ) + url_title: str = Field( + None, + description="A title for your supplementary URL, otherwise just the URL is shown", + ) + priority: conint(ge=1, le=5) = Field( + None, + description="send as -2 to generate no notification/alert, -1 to always send as a quiet notification," + " 1 to display as high-priority and bypass the user's quiet hours," + " or 2 to also require confirmation from the user", + ) + sound: PushoverSound = Field( + None, + description="The name of one of the sounds supported by device clients to override the " + "user's default sound choice ", + ) + timestamp: datetime = Field( + None, + description="A Unix timestamp of your message's date and time to display to the user," + " rather than the time your message is received by our API ", + ) + html: bool = Field(None, description="Enable HTML formatting") + monospace: bool = Field(None, description="Enable monospace messages") + retry: conint(ge=30) = Field( + None, + description="Specifies how often (in seconds) the Pushover servers will send the same notification to the user." + " requires setting priorty to 2", + ) + expire: conint(le=10800) = Field( + None, + description="Specifies how many seconds your notification will continue to be retried for " + "(every retry seconds). requires setting priorty to 2", + ) + callback: HttpUrl = Field( + None, + description="A publicly-accessible URL that our servers will send a request to when the user has" + " acknowledged your notification. requires setting priorty to 2", + ) + tags: PushoverBaseSchema.single_or_list(str) = Field( + None, + description="Arbitrary tags which will be stored with the receipt on our servers", + ) + + @validator("html", "monospace") + def bool_to_num(cls, v): + return int(v) + + @validator("user", "device", "tags") + def to_csv(cls, v): + return cls.to_comma_separated(v) + + @validator("timestamp") + def to_timestamp(cls, v: datetime): + return v.timestamp() + + @root_validator + def html_or_monospace(cls, values): + if all(value in values for value in ("html", "monospace")): + raise ValueError("Cannot use both 'html' and 'monospace'") + return values class PushoverMixin: @@ -13,26 +132,16 @@ class PushoverMixin: path_to_errors = ("errors",) -class PushoverResourceMixin(PushoverMixin): - _required = {"required": ["token"]} - - _schema = { - "type": "object", - "properties": { - "token": {"type": "string", "title": "your application's API token"} - }, - } - - -class PushoverSounds(PushoverResourceMixin, ProviderResource): +class PushoverSounds(PushoverMixin, ProviderResource): resource_name = "sounds" sounds_url = "sounds.json" - def _get_resource(self, data: dict): - url = self.base_url + self.sounds_url - params = {"token": data["token"]} + schema_model = PushoverBaseSchema + + def _get_resource(self, data: PushoverBaseSchema): + url = urljoin(self.base_url, self.sounds_url) response, errors = requests.get( - url, params=params, path_to_errors=self.path_to_errors + url, params=data.to_dict(), path_to_errors=self.path_to_errors ) if errors: raise ResourceError( @@ -45,15 +154,16 @@ def _get_resource(self, data: dict): return list(response.json()["sounds"].keys()) -class PushoverLimits(PushoverResourceMixin, ProviderResource): +class PushoverLimits(PushoverMixin, ProviderResource): resource_name = "limits" limits_url = "apps/limits.json" - def _get_resource(self, data: dict): - url = self.base_url + self.limits_url - params = {"token": data["token"]} + schema_model = PushoverBaseSchema + + def _get_resource(self, data: PushoverBaseSchema): + url = urljoin(self.base_url, self.limits_url) response, errors = requests.get( - url, params=params, path_to_errors=self.path_to_errors + url, params=data.to_dict(), path_to_errors=self.path_to_errors ) if errors: raise ResourceError( @@ -75,105 +185,15 @@ class Pushover(PushoverMixin, Provider): _resources = {"sounds": PushoverSounds(), "limits": PushoverLimits()} - _required = {"required": ["user", "message", "token"]} - _schema = { - "type": "object", - "properties": { - "user": one_or_more( - { - "type": "string", - "title": "the user/group key (not e-mail address) of your user (or you)", - } - ), - "message": {"type": "string", "title": "your message"}, - "title": { - "type": "string", - "title": "your message's title, otherwise your app's name is used", - }, - "token": {"type": "string", "title": "your application's API token"}, - "device": one_or_more( - { - "type": "string", - "title": "your user's device name to send the message directly to that device", - } - ), - "priority": { - "type": "integer", - "minimum": -2, - "maximum": 2, - "title": "notification priority", - }, - "url": { - "type": "string", - "format": "uri", - "title": "a supplementary URL to show with your message", - }, - "url_title": { - "type": "string", - "title": "a title for your supplementary URL, otherwise just the URL is shown", - }, - "sound": { - "type": "string", - "title": "the name of one of the sounds supported by device clients to override the " - "user's default sound choice. See `sounds` resource", - }, - "timestamp": { - "type": ["integer", "string"], - "format": "timestamp", - "minimum": 0, - "title": "a Unix timestamp of your message's date and time to display to the user, " - "rather than the time your message is received by our API", - }, - "retry": { - "type": "integer", - "minimum": 30, - "title": "how often (in seconds) the Pushover servers will send the same notification to the " - "user. priority must be set to 2", - }, - "expire": { - "type": "integer", - "maximum": 86400, - "title": "how many seconds your notification will continue to be retried for. " - "priority must be set to 2", - }, - "callback": { - "type": "string", - "format": "uri", - "title": "a publicly-accessible URL that our servers will send a request to when the user" - " has acknowledged your notification. priority must be set to 2", - }, - "html": {"type": "boolean", "title": "enable HTML formatting"}, - "attachment": { - "type": "string", - "format": "valid_file", - "title": "an image attachment to send with the message", - }, - }, - "additionalProperties": False, - } - - def _prepare_data(self, data: dict) -> dict: - data["user"] = list_to_commas(data["user"]) - if data.get("device"): - data["device"] = list_to_commas(data["device"]) - if data.get("html") is not None: - data["html"] = int(data["html"]) - if data.get("attachment") and not isinstance(data["attachment"], list): - data["attachment"] = [data["attachment"]] - return data + schema_model = PushoverSchema def _send_notification(self, data: dict) -> Response: - url = self.base_url + self.message_url - headers = {} + url = urljoin(self.base_url, self.message_url) files = [] if data.get("attachment"): files = requests.file_list_for_request(data["attachment"], "attachment") response, errors = requests.post( - url, - data=data, - headers=headers, - files=files, - path_to_errors=self.path_to_errors, + url, data=data, files=files, path_to_errors=self.path_to_errors ) return self.create_response(data, response, errors) From 68e78f586887f2c8ab0e4bf967fe57345fc48cec Mon Sep 17 00:00:00 2001 From: liiight Date: Sat, 29 Feb 2020 01:43:56 +0200 Subject: [PATCH 030/137] typo --- notifiers/providers/pushover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notifiers/providers/pushover.py b/notifiers/providers/pushover.py index efe5b217..19d2c87d 100644 --- a/notifiers/providers/pushover.py +++ b/notifiers/providers/pushover.py @@ -90,7 +90,7 @@ class PushoverSchema(PushoverBaseSchema): retry: conint(ge=30) = Field( None, description="Specifies how often (in seconds) the Pushover servers will send the same notification to the user." - " requires setting priorty to 2", + " requires setting priority to 2", ) expire: conint(le=10800) = Field( None, From 243a1e4d27e60642d99e4cc8a17862f04bc03f17 Mon Sep 17 00:00:00 2001 From: liiight Date: Sat, 29 Feb 2020 11:31:58 +0200 Subject: [PATCH 031/137] use baseschema class for gitter --- notifiers/providers/gitter.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/notifiers/providers/gitter.py b/notifiers/providers/gitter.py index 6223eebe..95703a1d 100644 --- a/notifiers/providers/gitter.py +++ b/notifiers/providers/gitter.py @@ -10,14 +10,16 @@ from ..utils import requests -class GitterRoomSchema(SchemaModel): +class GitterSchemaBase(SchemaModel): token: str = Field(..., description="Access token") + + +class GitterRoomSchema(GitterSchemaBase): filter: str = Field(None, description="Filter results") -class GitterSchema(SchemaModel): +class GitterSchema(GitterSchemaBase): text: str = Field(..., description="Body of the message", alias="message") - token: str = Field(..., description="Access token") room_id: str = Field(..., description="ID of the room to send the notification to") @@ -86,7 +88,7 @@ def metadata(self) -> dict: return metadata def _send_notification(self, data: GitterSchema) -> Response: - data = data.dict() + data = data.to_dict() room_id = data.pop("room_id") url = urljoin(self.base_url, self.message_url.format(room_id=room_id)) From a5a894150ff16f049a4ef3b245c617e34055da28 Mon Sep 17 00:00:00 2001 From: liiight Date: Sat, 29 Feb 2020 11:33:29 +0200 Subject: [PATCH 032/137] change order in join schema --- notifiers/providers/join.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notifiers/providers/join.py b/notifiers/providers/join.py index f82f9774..673e1b1c 100644 --- a/notifiers/providers/join.py +++ b/notifiers/providers/join.py @@ -41,7 +41,7 @@ class JoinSchema(JoinBaseSchema): description="Usually used as a Tasker or EventGhost command." " Can also be used with URLs and Files to add a description for those elements", ) - device_id: Union[str, JoinGroup] = Field( + device_id: Union[JoinGroup, str] = Field( JoinGroup.all_, description="The device ID or group ID of the device you want to send the message to", alias="deviceId", From 9d9487a02ba468bcc81f4d1e02725f01ff84a4ec Mon Sep 17 00:00:00 2001 From: liiight Date: Sat, 29 Feb 2020 11:35:00 +0200 Subject: [PATCH 033/137] used schema helper to convert to dict --- notifiers/providers/join.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notifiers/providers/join.py b/notifiers/providers/join.py index 673e1b1c..a0c2c4c5 100644 --- a/notifiers/providers/join.py +++ b/notifiers/providers/join.py @@ -182,8 +182,8 @@ class JoinMixin: @staticmethod def _join_request(url: str, data: JoinBaseSchema) -> tuple: # Can 't use generic requests util since API doesn't always return error status + params = data.to_dict() errors = None - params = json.loads(data.json(by_alias=True, exclude_none=True)) try: response = requests.get(url, params=params) response.raise_for_status() From d5442d993dc3fba1406925eda1c8c6b0423fe572 Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 1 Mar 2020 10:57:07 +0200 Subject: [PATCH 034/137] converted simplepush.py --- notifiers/providers/simplepush.py | 35 ++++++++++++++----------------- 1 file changed, 16 insertions(+), 19 deletions(-) diff --git a/notifiers/providers/simplepush.py b/notifiers/providers/simplepush.py index 98f2dc53..57ec16a0 100644 --- a/notifiers/providers/simplepush.py +++ b/notifiers/providers/simplepush.py @@ -1,34 +1,31 @@ +from pydantic import Field + from ..models.provider import Provider +from ..models.provider import SchemaModel from ..models.response import Response from ..utils import requests +class SimplePushSchema(SchemaModel): + key: str = Field(..., description="Your user key") + message: str = Field(..., description="Your message", alias="msg") + title: str = Field(None, description="Message title") + event: str = Field(None, description="Event Id") + + class SimplePush(Provider): """Send SimplePush notifications""" base_url = "https://api.simplepush.io/send" site_url = "https://simplepush.io/" name = "simplepush" + path_to_errors = ("message",) + + schema_model = SimplePushSchema - _required = {"required": ["key", "message"]} - _schema = { - "type": "object", - "properties": { - "key": {"type": "string", "title": "your user key"}, - "message": {"type": "string", "title": "your message"}, - "title": {"type": "string", "title": "message title"}, - "event": {"type": "string", "title": "Event ID"}, - }, - "additionalProperties": False, - } - - def _prepare_data(self, data: dict) -> dict: - data["msg"] = data.pop("message") - return data - - def _send_notification(self, data: dict) -> Response: - path_to_errors = ("message",) + def _send_notification(self, data: SimplePushSchema) -> Response: + data = data.to_dict() response, errors = requests.post( - self.base_url, data=data, path_to_errors=path_to_errors + self.base_url, data=data, path_to_errors=self.path_to_errors ) return self.create_response(data, response, errors) From e5dfd24fe6e56b7d4952271b079bef12c3d468dc Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 1 Mar 2020 20:36:52 +0200 Subject: [PATCH 035/137] added more stuff --- notifiers/providers/__init__.py | 2 +- notifiers/providers/slack/__init__.py | 1 + notifiers/providers/slack/blocks.py | 51 +++++++++++ notifiers/providers/slack/common.py | 85 +++++++++++++++++++ notifiers/providers/slack/elements.py | 84 ++++++++++++++++++ .../providers/{slack.py => slack/main.py} | 78 +++++++++++++---- 6 files changed, 285 insertions(+), 16 deletions(-) create mode 100644 notifiers/providers/slack/__init__.py create mode 100644 notifiers/providers/slack/blocks.py create mode 100644 notifiers/providers/slack/common.py create mode 100644 notifiers/providers/slack/elements.py rename notifiers/providers/{slack.py => slack/main.py} (67%) diff --git a/notifiers/providers/__init__.py b/notifiers/providers/__init__.py index ef8defc0..b2743b4f 100644 --- a/notifiers/providers/__init__.py +++ b/notifiers/providers/__init__.py @@ -9,7 +9,7 @@ from . import pushbullet from . import pushover from . import simplepush -from . import slack +from . import slack_ from . import statuspage from . import telegram from . import twilio diff --git a/notifiers/providers/slack/__init__.py b/notifiers/providers/slack/__init__.py new file mode 100644 index 00000000..3cf1a532 --- /dev/null +++ b/notifiers/providers/slack/__init__.py @@ -0,0 +1 @@ +from .main import Slack # noqa: F401 diff --git a/notifiers/providers/slack/blocks.py b/notifiers/providers/slack/blocks.py new file mode 100644 index 00000000..114aaf4a --- /dev/null +++ b/notifiers/providers/slack/blocks.py @@ -0,0 +1,51 @@ +from enum import Enum +from typing import List + +from pydantic import constr +from pydantic import Field + +from notifiers.models.provider import SchemaModel +from notifiers.providers.slack.common import SlackBlockTextObject +from notifiers.providers.slack.elements import SlackElementTypes + + +class SlackBlockType(Enum): + section = "section" + divider = "divider" + image = "image" + actions = "actions" + context = "context" + file = "file" + + +class SlackBaseBlock(SchemaModel): + type: SlackBlockType = Field(..., description="The type of block") + block_id: constr(max_length=255) = Field( + None, + description="A string acting as a unique identifier for a block. " + "You can use this block_id when you receive an interaction payload to identify the source of " + "the action. If not specified, one will be generated. Maximum length for this field is " + "255 characters. block_id should be unique for each message and each iteration of a message. " + "If a message is updated, use a new block_id", + ) + + +class SlackSectionBlock(SlackBaseBlock): + type = SlackBlockType.section + text: SlackBlockTextObject = Field( + None, + description="The text for the block, in the form of a text object." + " Maximum length for the text in this field is 3000 characters." + " This field is not required if a valid array of fields objects is provided instead", + ) + + fields: List[SlackBlockTextObject] = Field( + None, + description="An array of text objects. Any text objects included with fields will be rendered in a compact " + "format that allows for 2 columns of side-by-side text. Maximum number of items is 10." + " Maximum length for the text in each item is 2000 characters", + max_length=10, + ) + accessory: SlackElementTypes = Field( + None, description="One of the available element objects" + ) diff --git a/notifiers/providers/slack/common.py b/notifiers/providers/slack/common.py new file mode 100644 index 00000000..c5b56cff --- /dev/null +++ b/notifiers/providers/slack/common.py @@ -0,0 +1,85 @@ +from enum import Enum + +from pydantic import constr +from pydantic import create_model +from pydantic import Field +from pydantic import HttpUrl + +from notifiers.models.provider import SchemaModel + + +class SlackTextType(Enum): + plain_text = "plain_text" + markdown = "mrkdwn" + + +class SlackBlockTextObject(SchemaModel): + """An object containing some text, formatted either as plain_text or using mrkdwn""" + + type: SlackTextType = Field( + ..., description="The formatting to use for this text object" + ) + text: constr(max_length=3000) = Field( + ..., + description="The text for the block. This field accepts any of the standard text" + " formatting markup when type is mrkdwn", + ) + emoji: bool = Field( + None, + description="Indicates whether emojis in a text field should be escaped into the colon emoji format. " + "This field is only usable when type is plain_text", + ) + verbatim: bool = Field( + None, + description="When set to false (as is default) URLs will be auto-converted into links," + " conversation names will be link-ified, and certain mentions will be automatically parsed." + " Using a value of true will skip any preprocessing of this nature, although you can still" + " include manual parsing strings. This field is only usable when type is mrkdwn.", + ) + + class Config: + json_encoders = {SlackTextType: lambda v: v.value} + + +def _text_object_factory(max_length: int, type_: SlackTextType = None): + """Returns a custom text object schema""" + type_value = (SlackTextType, type_) if type_ else (SlackTextType, ...) + return create_model( + "CustomTextObject", + type=type_value, + text=(constr(max_length=max_length), ...), + __base__=SlackBlockTextObject, + ) + + +class SlackOption(SchemaModel): + text: _text_object_factory(type_=SlackTextType.plain_text, max_length=75) = Field( + ..., + description="A plain_text only text object that defines the text shown in the option on the menu." + " Maximum length for the text in this field is 75 characters", + ) + value: constr(max_length=75) = Field( + ..., + description="The string value that will be passed to your app when this option is chosen", + ) + description: _text_object_factory( + type_=SlackTextType.plain_text, max_length=75 + ) = Field( + None, + description="A plain_text only text object that defines a line of descriptive text shown below the " + "text field beside the radio button.", + ) + url: HttpUrl = Field( + None, + description="A URL to load in the user's browser when the option is clicked. " + "The url attribute is only available in overflow menus. Maximum length for this field is 3000 characters. " + "If you're using url, you'll still receive an interaction payload and will need to send an " + "acknowledgement response.", + ) + + +class SlackConfirmationDialog(SchemaModel): + title: _text_object_factory(type_=SlackTextType.plain_text, max_length=100) + text: _text_object_factory(max_length=300) + confirm: _text_object_factory(max_length=30) + deny: _text_object_factory(type_=SlackTextType.plain_text, max_length=30) diff --git a/notifiers/providers/slack/elements.py b/notifiers/providers/slack/elements.py new file mode 100644 index 00000000..edea14e5 --- /dev/null +++ b/notifiers/providers/slack/elements.py @@ -0,0 +1,84 @@ +from enum import Enum +from typing import List +from typing import Union + +from pydantic import constr +from pydantic import Field +from pydantic import HttpUrl + +from notifiers.models.provider import SchemaModel +from notifiers.providers.slack.common import _text_object_factory +from notifiers.providers.slack.common import SlackConfirmationDialog +from notifiers.providers.slack.common import SlackOption +from notifiers.providers.slack.common import SlackTextType + + +class SlackElementType(Enum): + button = "button" + checkboxes = "checkboxes" + + +class SlackBaseElementSchema(SchemaModel): + type: SlackElementType = Field(..., description="The type of element") + action_id: constr(max_length=255) = Field( + ..., + description="An identifier for this action. You can use this when you receive an interaction payload to " + "identify the source of the action. Should be unique among all other action_ids used " + "elsewhere by your app", + ) + + class Config: + json_encoders = {SlackElementType: lambda v: v.value} + + +class SlackButtonElementStyle(Enum): + primary = "primary" + danger = "danger" + default = None + + +class SlackButtonElement(SlackBaseElementSchema): + """An interactive component that inserts a button. + The button can be a trigger for anything from opening a simple link to starting a complex workflow.""" + + type = SlackElementType.button + text: _text_object_factory(type_=SlackTextType.plain_text, max_length=75) + url: HttpUrl = Field( + None, + description="A URL to load in the user's browser when the button is clicked. " + "Maximum length for this field is 3000 characters. If you're using url," + " you'll still receive an interaction payload and will need to send an acknowledgement response", + ) + value: constr(max_length=2000) = Field( + None, + description="The value to send along with the interaction payload. " + "Maximum length for this field is 2000 characters", + ) + style: SlackButtonElementStyle = Field( + None, + description="Decorates buttons with alternative visual color schemes. Use this option with restraint", + ) + confirm: SlackConfirmationDialog = Field( + None, + description="A confirm object that defines an optional confirmation dialog after the button is clicked.", + ) + + +class SlackCheckboxElement(SlackBaseElementSchema): + """A checkbox group that allows a user to choose multiple items from a list of possible options""" + + type = SlackElementType.checkboxes + options: List[SlackOption] = Field(..., description="An array of option objects") + initial_options: List[SlackOption] = Field( + ..., + description="An array of option objects that exactly matches one or more of the options within options." + " These options will be selected when the checkbox group initially loads", + ) + confirm: SlackConfirmationDialog = Field( + None, + description="A confirm object that defines an optional confirmation dialog that appears after " + "clicking one of the checkboxes in this element.", + ) + + +SlackElementTypes = Union[SlackButtonElement] diff --git a/notifiers/providers/slack.py b/notifiers/providers/slack/main.py similarity index 67% rename from notifiers/providers/slack.py rename to notifiers/providers/slack/main.py index bfde037b..5cd8ee85 100644 --- a/notifiers/providers/slack.py +++ b/notifiers/providers/slack/main.py @@ -1,6 +1,64 @@ -from ..models.provider import Provider -from ..models.response import Response -from ..utils import requests +from typing import List +from typing import Union + +from pydantic import Field +from pydantic import HttpUrl +from pydantic import validator + +from notifiers.models.provider import Provider +from notifiers.models.provider import SchemaModel +from notifiers.models.response import Response +from notifiers.providers.slack.blocks import SlackSectionBlock +from notifiers.providers.slack_ import SlackAttachmentSchema +from notifiers.utils import requests + + +class SlackSchema(SchemaModel): + webhook_url: HttpUrl = Field( + ..., + description="The webhook URL to use. Register one at https://my.slack.com/services/new/incoming-webhook/", + ) + message: str = Field( + ..., + description="The usage of this field changes depending on whether you're using blocks or not." + " If you are, this is used as a fallback string to display in notifications." + " If you aren't, this is the main body text of the message." + " It can be formatted as plain text, or with mrkdwn." + " This field is not enforced as required when using blocks, " + "however it is highly recommended that you include it as the aforementioned fallback.", + alias="text", + ) + blocks: List[Union[SlackSectionBlock]] = Field( + None, + description="An array of layout blocks in the same format as described in the building blocks guide.", + max_length=50, + ) + attachments: List[SlackAttachmentSchema] = Field( + None, + description="An array of legacy secondary attachments. We recommend you use blocks instead.", + ) + thread_ts: str = Field( + None, description="The ID of another un-threaded message to reply to" + ) + markdown: bool = Field( + None, + description="Determines whether the text field is rendered according to mrkdwn formatting or not." + " Defaults to true", + alias="mrkdwn", + ) + icon_url: HttpUrl = Field(None, description="Override bot icon with image URL") + icon_emoji: str = Field(None, description="Override bot icon with emoji name") + username: str = Field(None, description="Override the displayed bot name") + channel: str = Field( + None, description="Override default channel or private message" + ) + unfurl_links: bool = Field( + None, description="Avoid or enable automatic attachment creation from URLs" + ) + + @validator("icon_emoji") + def emoji(cls, v: str): + return f':{v.strip(":")}:' class Slack(Provider): @@ -10,6 +68,8 @@ class Slack(Provider): site_url = "https://api.slack.com/incoming-webhooks" name = "slack" + schema_model = SlackSchema + __fields = { "type": "array", "title": "Fields are displayed in a table on the message", @@ -130,18 +190,6 @@ class Slack(Provider): "additionalProperties": False, } - def _prepare_data(self, data: dict) -> dict: - text = data.pop("message") - data["text"] = text - if data.get("icon_emoji"): - icon_emoji = data["icon_emoji"] - if not icon_emoji.startswith(":"): - icon_emoji = f":{icon_emoji}" - if not icon_emoji.endswith(":"): - icon_emoji += ":" - data["icon_emoji"] = icon_emoji - return data - def _send_notification(self, data: dict) -> Response: url = data.pop("webhook_url") response, errors = requests.post(url, json=data) From a8ab8d55694c471acf28d9816e521ba9c35c5568 Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 1 Mar 2020 20:37:48 +0200 Subject: [PATCH 036/137] added stub schema --- notifiers/providers/slack/main.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/notifiers/providers/slack/main.py b/notifiers/providers/slack/main.py index 5cd8ee85..6a3e0d1c 100644 --- a/notifiers/providers/slack/main.py +++ b/notifiers/providers/slack/main.py @@ -9,10 +9,13 @@ from notifiers.models.provider import SchemaModel from notifiers.models.response import Response from notifiers.providers.slack.blocks import SlackSectionBlock -from notifiers.providers.slack_ import SlackAttachmentSchema from notifiers.utils import requests +class SlackAttachmentSchema(SchemaModel): + pass + + class SlackSchema(SchemaModel): webhook_url: HttpUrl = Field( ..., From 85f6670c597349e24dc8c5db2e149b06e4ff07b6 Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 1 Mar 2020 20:38:45 +0200 Subject: [PATCH 037/137] added docstring --- notifiers/providers/slack/common.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/notifiers/providers/slack/common.py b/notifiers/providers/slack/common.py index c5b56cff..7b5db0d6 100644 --- a/notifiers/providers/slack/common.py +++ b/notifiers/providers/slack/common.py @@ -53,6 +53,9 @@ def _text_object_factory(max_length: int, type_: SlackTextType = None): class SlackOption(SchemaModel): + """An object that represents a single selectable item in a select menu, multi-select menu, radio button group, + or overflow menu.""" + text: _text_object_factory(type_=SlackTextType.plain_text, max_length=75) = Field( ..., description="A plain_text only text object that defines the text shown in the option on the menu." @@ -79,6 +82,9 @@ class SlackOption(SchemaModel): class SlackConfirmationDialog(SchemaModel): + """An object that defines a dialog that provides a confirmation step to any interactive element. + This dialog will ask the user to confirm their action by offering a confirm and deny buttons.""" + title: _text_object_factory(type_=SlackTextType.plain_text, max_length=100) text: _text_object_factory(max_length=300) confirm: _text_object_factory(max_length=30) From f6a882424d223764aefb78c2ea477f4ecbdd5ae3 Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 1 Mar 2020 20:46:29 +0200 Subject: [PATCH 038/137] added element --- notifiers/providers/slack/common.py | 5 ++++- notifiers/providers/slack/elements.py | 31 ++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/notifiers/providers/slack/common.py b/notifiers/providers/slack/common.py index 7b5db0d6..bb2ca0bf 100644 --- a/notifiers/providers/slack/common.py +++ b/notifiers/providers/slack/common.py @@ -1,4 +1,5 @@ from enum import Enum +from typing import Type from pydantic import constr from pydantic import create_model @@ -41,7 +42,9 @@ class Config: json_encoders = {SlackTextType: lambda v: v.value} -def _text_object_factory(max_length: int, type_: SlackTextType = None): +def _text_object_factory( + max_length: int, type_: SlackTextType = None +) -> Type[SlackBlockTextObject]: """Returns a custom text object schema""" type_value = (SlackTextType, type_) if type_ else (SlackTextType, ...) return create_model( diff --git a/notifiers/providers/slack/elements.py b/notifiers/providers/slack/elements.py index edea14e5..b2cb7a58 100644 --- a/notifiers/providers/slack/elements.py +++ b/notifiers/providers/slack/elements.py @@ -1,3 +1,4 @@ +from datetime import date from enum import Enum from typing import List from typing import Union @@ -5,6 +6,7 @@ from pydantic import constr from pydantic import Field from pydantic import HttpUrl +from pydantic import validator from notifiers.models.provider import SchemaModel from notifiers.providers.slack.common import _text_object_factory @@ -16,6 +18,7 @@ class SlackElementType(Enum): button = "button" checkboxes = "checkboxes" + date_picker = "datepicker" class SlackBaseElementSchema(SchemaModel): @@ -81,4 +84,30 @@ class SlackCheckboxElement(SlackBaseElementSchema): ) -SlackElementTypes = Union[SlackButtonElement] +class SlackDatePickerElement(SlackBaseElementSchema): + """An element which lets users easily select a date from a calendar style UI.""" + + placeholder: _text_object_factory( + type_=SlackTextType.plain_text, max_length=150 + ) = Field( + None, + description="A plain_text only text object that defines the placeholder text shown on the datepicker." + " Maximum length for the text in this field is 150 characters", + ) + initial_date: date = Field( + None, description="The initial date that is selected when the element is loaded" + ) + confirm: SlackConfirmationDialog = Field( + None, + description="A confirm object that defines an optional confirmation dialog that appears" + " after a date is selected.", + ) + + @validator("initial_date") + def format_date(cls, v: date): + return str(v) + + +SlackElementTypes = Union[ + SlackButtonElement, SlackCheckboxElement, SlackDatePickerElement +] From 88bdeb44d90e1f8b58f6a8db1c0e6cd79ca5b805 Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 1 Mar 2020 20:48:37 +0200 Subject: [PATCH 039/137] added image element --- notifiers/providers/slack/elements.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/notifiers/providers/slack/elements.py b/notifiers/providers/slack/elements.py index b2cb7a58..7c85cbcf 100644 --- a/notifiers/providers/slack/elements.py +++ b/notifiers/providers/slack/elements.py @@ -19,6 +19,7 @@ class SlackElementType(Enum): button = "button" checkboxes = "checkboxes" date_picker = "datepicker" + image = "image" class SlackBaseElementSchema(SchemaModel): @@ -108,6 +109,17 @@ def format_date(cls, v: date): return str(v) +class SlackImageElement(SlackBaseElementSchema): + """A plain-text summary of the image. This should not contain any markup""" + + type = SlackElementType.image + image_url: HttpUrl = Field(..., description="The URL of the image to be displayed") + alt_text: str = Field( + ..., + description="A plain-text summary of the image. This should not contain any markup", + ) + + SlackElementTypes = Union[ - SlackButtonElement, SlackCheckboxElement, SlackDatePickerElement + SlackButtonElement, SlackCheckboxElement, SlackDatePickerElement, SlackImageElement ] From 0fa42b5ef8e59571f5ad7eacf77a9fa2610f74dd Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 1 Mar 2020 21:01:22 +0200 Subject: [PATCH 040/137] added multiselect menu --- notifiers/providers/slack/common.py | 15 ++++++++ notifiers/providers/slack/elements.py | 49 ++++++++++++++++++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/notifiers/providers/slack/common.py b/notifiers/providers/slack/common.py index bb2ca0bf..20e7e514 100644 --- a/notifiers/providers/slack/common.py +++ b/notifiers/providers/slack/common.py @@ -1,4 +1,5 @@ from enum import Enum +from typing import List from typing import Type from pydantic import constr @@ -84,6 +85,20 @@ class SlackOption(SchemaModel): ) +class SlackOptionGroup(SchemaModel): + """Provides a way to group options in a select menu or multi-select menu""" + + label: _text_object_factory(type_=SlackTextType.plain_text, max_length=75) = Field( + ..., + description="A plain_text only text object that defines the label shown above this group of options", + ) + options: List[SlackOption] = Field( + ..., + description="An array of option objects that belong to this specific group. Maximum of 100 items", + max_items=100, + ) + + class SlackConfirmationDialog(SchemaModel): """An object that defines a dialog that provides a confirmation step to any interactive element. This dialog will ask the user to confirm their action by offering a confirm and deny buttons.""" diff --git a/notifiers/providers/slack/elements.py b/notifiers/providers/slack/elements.py index 7c85cbcf..7d5fef01 100644 --- a/notifiers/providers/slack/elements.py +++ b/notifiers/providers/slack/elements.py @@ -3,15 +3,18 @@ from typing import List from typing import Union +from pydantic import conint from pydantic import constr from pydantic import Field from pydantic import HttpUrl +from pydantic import root_validator from pydantic import validator from notifiers.models.provider import SchemaModel from notifiers.providers.slack.common import _text_object_factory from notifiers.providers.slack.common import SlackConfirmationDialog from notifiers.providers.slack.common import SlackOption +from notifiers.providers.slack.common import SlackOptionGroup from notifiers.providers.slack.common import SlackTextType @@ -20,6 +23,7 @@ class SlackElementType(Enum): checkboxes = "checkboxes" date_picker = "datepicker" image = "image" + multi_static_select = "multi_static_select" class SlackBaseElementSchema(SchemaModel): @@ -120,6 +124,49 @@ class SlackImageElement(SlackBaseElementSchema): ) +class SlackMultiSelectMenuElement(SlackBaseElementSchema): + type = SlackElementType.multi_static_select + placeholder: _text_object_factory( + type_=SlackTextType.plain_text, max_length=150 + ) = Field( + ..., + description="A plain_text only text object that defines the placeholder text shown on the menu", + ) + options: List[SlackOption] = Field( + None, description="An array of option objects.", max_items=100 + ) + option_groups: List[SlackOptionGroup] = Field( + None, description="An array of option group objects" + ) + initial_options: List[SlackOption] = Field( + None, + description="An array of option objects that exactly match one or more of the options within options " + "or option_groups. These options will be selected when the menu initially loads.", + ) + confirm: SlackConfirmationDialog = Field( + None, + description="A confirm object that defines an optional confirmation dialog that appears before " + "the multi-select choices are submitted", + ) + max_selected_items: conint(ge=1) = Field( + None, + description="Specifies the maximum number of items that can be selected in the menu", + ) + + @root_validator + def option_check(cls, values): + if not any(value in values for value in ("options", "option_groups")): + raise ValueError("Either 'options' or 'option_groups' are required") + + if all(value in values for value in ("options", "option_groups")): + raise ValueError("Cannot use both 'options' and 'option_groups'") + return values + + SlackElementTypes = Union[ - SlackButtonElement, SlackCheckboxElement, SlackDatePickerElement, SlackImageElement + SlackButtonElement, + SlackCheckboxElement, + SlackDatePickerElement, + SlackImageElement, + SlackMultiSelectMenuElement, ] From 140110b428e4d1f2c766cbee1a4831c0543b1927 Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 1 Mar 2020 21:12:12 +0200 Subject: [PATCH 041/137] added more elements --- notifiers/providers/slack/elements.py | 53 ++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 10 deletions(-) diff --git a/notifiers/providers/slack/elements.py b/notifiers/providers/slack/elements.py index 7d5fef01..d6b42ea0 100644 --- a/notifiers/providers/slack/elements.py +++ b/notifiers/providers/slack/elements.py @@ -3,10 +3,10 @@ from typing import List from typing import Union -from pydantic import conint from pydantic import constr from pydantic import Field from pydantic import HttpUrl +from pydantic import PositiveInt from pydantic import root_validator from pydantic import validator @@ -24,6 +24,8 @@ class SlackElementType(Enum): date_picker = "datepicker" image = "image" multi_static_select = "multi_static_select" + multi_external_select = "multi_external_select" + multi_users_select = "multi_users_select" class SlackBaseElementSchema(SchemaModel): @@ -124,20 +126,13 @@ class SlackImageElement(SlackBaseElementSchema): ) -class SlackMultiSelectMenuElement(SlackBaseElementSchema): - type = SlackElementType.multi_static_select +class SlackMultiSelectBaseElement(SlackBaseElementSchema): placeholder: _text_object_factory( type_=SlackTextType.plain_text, max_length=150 ) = Field( ..., description="A plain_text only text object that defines the placeholder text shown on the menu", ) - options: List[SlackOption] = Field( - None, description="An array of option objects.", max_items=100 - ) - option_groups: List[SlackOptionGroup] = Field( - None, description="An array of option group objects" - ) initial_options: List[SlackOption] = Field( None, description="An array of option objects that exactly match one or more of the options within options " @@ -148,11 +143,24 @@ class SlackMultiSelectMenuElement(SlackBaseElementSchema): description="A confirm object that defines an optional confirmation dialog that appears before " "the multi-select choices are submitted", ) - max_selected_items: conint(ge=1) = Field( + max_selected_items: PositiveInt = Field( None, description="Specifies the maximum number of items that can be selected in the menu", ) + +class SlackMultiSelectMenuElement(SlackMultiSelectBaseElement): + """This is the simplest form of select menu, with a static list of options passed in when defining the element.""" + + type = SlackElementType.multi_static_select + + options: List[SlackOption] = Field( + None, description="An array of option objects.", max_items=100 + ) + option_groups: List[SlackOptionGroup] = Field( + None, description="An array of option group objects" + ) + @root_validator def option_check(cls, values): if not any(value in values for value in ("options", "option_groups")): @@ -163,10 +171,35 @@ def option_check(cls, values): return values +class SlackMultiSelectExternalMenuElement(SlackMultiSelectBaseElement): + """This menu will load its options from an external data source, allowing for a dynamic list of options.""" + + type = SlackElementType.multi_external_select + min_query_length: PositiveInt = Field( + None, + description="When the typeahead field is used, a request will be sent on every character change. " + "If you prefer fewer requests or more fully ideated queries, use the min_query_length attribute" + " to tell Slack the fewest number of typed characters required before dispatch", + ) + + +class SlackMultiSelectUserList(SlackMultiSelectBaseElement): + """This multi-select menu will populate its options with a list of Slack users visible to the + current user in the active workspace.""" + + type = SlackElementType.multi_users_select + initial_users: List[str] = Field( + None, + description="An array of user IDs of any valid users to be pre-selected when the menu loads.", + ) + + SlackElementTypes = Union[ SlackButtonElement, SlackCheckboxElement, SlackDatePickerElement, SlackImageElement, SlackMultiSelectMenuElement, + SlackMultiSelectExternalMenuElement, + SlackMultiSelectUserList, ] From 4ad976bf4c13f5bd4a9b5092c981d96a79554310 Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 1 Mar 2020 22:45:55 +0200 Subject: [PATCH 042/137] added more elements --- notifiers/providers/slack/elements.py | 78 +++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) diff --git a/notifiers/providers/slack/elements.py b/notifiers/providers/slack/elements.py index d6b42ea0..295b57c8 100644 --- a/notifiers/providers/slack/elements.py +++ b/notifiers/providers/slack/elements.py @@ -3,6 +3,7 @@ from typing import List from typing import Union +from pydantic import conint from pydantic import constr from pydantic import Field from pydantic import HttpUrl @@ -26,6 +27,9 @@ class SlackElementType(Enum): multi_static_select = "multi_static_select" multi_external_select = "multi_external_select" multi_users_select = "multi_users_select" + multi_conversations_select = "multi_conversations_select" + multi_channels_select = "multi_channels_select" + overflow = "overflow" class SlackBaseElementSchema(SchemaModel): @@ -194,6 +198,76 @@ class SlackMultiSelectUserList(SlackMultiSelectBaseElement): ) +class SlackMultiSelectConversations(SlackMultiSelectBaseElement): + """This multi-select menu will populate its options with a list of public and private channels, + DMs, and MPIMs visible to the current user in the active workspace""" + + type = SlackElementType.multi_conversations_select + initial_conversations: List[str] = Field( + None, + description="An array of one or more IDs of any valid conversations to be pre-selected when the menu loads", + ) + + +class SlackMultiSelectChannels(SlackMultiSelectBaseElement): + """This multi-select menu will populate its options with a list of public channels visible to the current + user in the active workspace""" + + type = SlackElementType.multi_channels_select + initial_channels: List[str] = Field( + None, + description="An array of one or more IDs of any valid public channel to be pre-selected when the menu loads", + ) + + +class SlackOverflowElement(SlackBaseElementSchema): + """This is like a cross between a button and a select menu - when a user clicks on this overflow button, + they will be presented with a list of options to choose from. Unlike the select menu, + there is no typeahead field, and the button always appears with an ellipsis ("…") rather than customisable text.""" + + type = SlackElementType.overflow + options: List[SlackOption] = Field( + ..., + description="An array of option objects to display in the menu", + min_items=2, + max_items=5, + ) + confirm: SlackConfirmationDialog = Field( + None, + description="A confirm object that defines an optional confirmation dialog that appears after a menu " + "item is selected", + ) + + +class SlackPlainTextInputElement(SlackBaseElementSchema): + """A plain-text input, similar to the HTML tag, creates a field where a user can enter freeform data. + It can appear as a single-line field or a larger textarea using the multiline flag.""" + + placeholder: _text_object_factory( + type_=SlackTextType.plain_text, max_length=150 + ) = Field( + None, + description="A plain_text only text object that defines the placeholder text shown in the plain-text input", + ) + initial_value: str = Field( + None, description="The initial value in the plain-text input when it is loaded" + ) + multiline: bool = Field( + None, + description="Indicates whether the input will be a single line (false) or a larger textarea (true)", + ) + min_length: conint(gt=0, le=3000) = Field( + None, + description="The minimum length of input that the user must provide. If the user provides less," + " they will receive an error", + ) + max_length: PositiveInt = Field( + None, + description="The maximum length of input that the user can provide. If the user provides more," + " they will receive an error", + ) + + SlackElementTypes = Union[ SlackButtonElement, SlackCheckboxElement, @@ -202,4 +276,8 @@ class SlackMultiSelectUserList(SlackMultiSelectBaseElement): SlackMultiSelectMenuElement, SlackMultiSelectExternalMenuElement, SlackMultiSelectUserList, + SlackMultiSelectConversations, + SlackMultiSelectChannels, + SlackOverflowElement, + SlackPlainTextInputElement, ] From 318c1de5e0d93796a317476e2453fb81556292cd Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 1 Mar 2020 22:50:46 +0200 Subject: [PATCH 043/137] added more elements --- notifiers/providers/slack/elements.py | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/notifiers/providers/slack/elements.py b/notifiers/providers/slack/elements.py index 295b57c8..e3171d29 100644 --- a/notifiers/providers/slack/elements.py +++ b/notifiers/providers/slack/elements.py @@ -30,6 +30,8 @@ class SlackElementType(Enum): multi_conversations_select = "multi_conversations_select" multi_channels_select = "multi_channels_select" overflow = "overflow" + plain_text_input = "plain_text_input" + radio_buttons = "radio_buttons" class SlackBaseElementSchema(SchemaModel): @@ -243,6 +245,7 @@ class SlackPlainTextInputElement(SlackBaseElementSchema): """A plain-text input, similar to the HTML tag, creates a field where a user can enter freeform data. It can appear as a single-line field or a larger textarea using the multiline flag.""" + type = SlackElementType.plain_text_input placeholder: _text_object_factory( type_=SlackTextType.plain_text, max_length=150 ) = Field( @@ -268,6 +271,23 @@ class SlackPlainTextInputElement(SlackBaseElementSchema): ) +class SlackRadioButtonGroupElement(SlackBaseElementSchema): + """A radio button group that allows a user to choose one item from a list of possible options""" + + type = SlackElementType.radio_buttons + options: List[SlackOption] = Field(..., description="An array of option objects") + initial_option: SlackOption = Field( + None, + description="An option object that exactly matches one of the options within options." + " This option will be selected when the radio button group initially loads.", + ) + confirm: SlackConfirmationDialog = Field( + None, + description="A confirm object that defines an optional confirmation dialog that appears after " + "clicking one of the radio buttons in this element", + ) + + SlackElementTypes = Union[ SlackButtonElement, SlackCheckboxElement, @@ -280,4 +300,5 @@ class SlackPlainTextInputElement(SlackBaseElementSchema): SlackMultiSelectChannels, SlackOverflowElement, SlackPlainTextInputElement, + SlackRadioButtonGroupElement, ] From 98ca28fb6cb17b4991031558df1ace4336ad2112 Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 1 Mar 2020 23:18:22 +0200 Subject: [PATCH 044/137] added all elements --- notifiers/providers/slack/blocks.py | 2 +- .../slack/{common.py => composition.py} | 0 notifiers/providers/slack/elements.py | 126 ++++++++++++++---- 3 files changed, 100 insertions(+), 28 deletions(-) rename notifiers/providers/slack/{common.py => composition.py} (100%) diff --git a/notifiers/providers/slack/blocks.py b/notifiers/providers/slack/blocks.py index 114aaf4a..abfe1838 100644 --- a/notifiers/providers/slack/blocks.py +++ b/notifiers/providers/slack/blocks.py @@ -5,7 +5,7 @@ from pydantic import Field from notifiers.models.provider import SchemaModel -from notifiers.providers.slack.common import SlackBlockTextObject +from notifiers.providers.slack.composition import SlackBlockTextObject from notifiers.providers.slack.elements import SlackElementTypes diff --git a/notifiers/providers/slack/common.py b/notifiers/providers/slack/composition.py similarity index 100% rename from notifiers/providers/slack/common.py rename to notifiers/providers/slack/composition.py diff --git a/notifiers/providers/slack/elements.py b/notifiers/providers/slack/elements.py index e3171d29..ce4e427f 100644 --- a/notifiers/providers/slack/elements.py +++ b/notifiers/providers/slack/elements.py @@ -12,11 +12,11 @@ from pydantic import validator from notifiers.models.provider import SchemaModel -from notifiers.providers.slack.common import _text_object_factory -from notifiers.providers.slack.common import SlackConfirmationDialog -from notifiers.providers.slack.common import SlackOption -from notifiers.providers.slack.common import SlackOptionGroup -from notifiers.providers.slack.common import SlackTextType +from notifiers.providers.slack.composition import _text_object_factory +from notifiers.providers.slack.composition import SlackConfirmationDialog +from notifiers.providers.slack.composition import SlackOption +from notifiers.providers.slack.composition import SlackOptionGroup +from notifiers.providers.slack.composition import SlackTextType class SlackElementType(Enum): @@ -24,17 +24,24 @@ class SlackElementType(Enum): checkboxes = "checkboxes" date_picker = "datepicker" image = "image" + overflow = "overflow" + plain_text_input = "plain_text_input" + radio_buttons = "radio_buttons" + multi_static_select = "multi_static_select" multi_external_select = "multi_external_select" multi_users_select = "multi_users_select" multi_conversations_select = "multi_conversations_select" multi_channels_select = "multi_channels_select" - overflow = "overflow" - plain_text_input = "plain_text_input" - radio_buttons = "radio_buttons" + static_select = "static_select" + external_select = "external_select" + conversations_select = "conversations_select" + users_select = "users_select" + channels_select = "channels_select" -class SlackBaseElementSchema(SchemaModel): + +class SlackBaseElement(SchemaModel): type: SlackElementType = Field(..., description="The type of element") action_id: constr(max_length=255) = Field( ..., @@ -53,7 +60,7 @@ class SlackButtonElementStyle(Enum): default = None -class SlackButtonElement(SlackBaseElementSchema): +class SlackButtonElement(SlackBaseElement): """An interactive component that inserts a button. The button can be a trigger for anything from opening a simple link to starting a complex workflow.""" @@ -80,7 +87,7 @@ class SlackButtonElement(SlackBaseElementSchema): ) -class SlackCheckboxElement(SlackBaseElementSchema): +class SlackCheckboxElement(SlackBaseElement): """A checkbox group that allows a user to choose multiple items from a list of possible options""" type = SlackElementType.checkboxes @@ -97,7 +104,7 @@ class SlackCheckboxElement(SlackBaseElementSchema): ) -class SlackDatePickerElement(SlackBaseElementSchema): +class SlackDatePickerElement(SlackBaseElement): """An element which lets users easily select a date from a calendar style UI.""" placeholder: _text_object_factory( @@ -121,7 +128,7 @@ def format_date(cls, v: date): return str(v) -class SlackImageElement(SlackBaseElementSchema): +class SlackImageElement(SlackBaseElement): """A plain-text summary of the image. This should not contain any markup""" type = SlackElementType.image @@ -132,7 +139,7 @@ class SlackImageElement(SlackBaseElementSchema): ) -class SlackMultiSelectBaseElement(SlackBaseElementSchema): +class SlackMultiSelectBaseElement(SlackBaseElement): placeholder: _text_object_factory( type_=SlackTextType.plain_text, max_length=150 ) = Field( @@ -155,7 +162,7 @@ class SlackMultiSelectBaseElement(SlackBaseElementSchema): ) -class SlackMultiSelectMenuElement(SlackMultiSelectBaseElement): +class SlackMultiStaticSelectMenuElement(SlackMultiSelectBaseElement): """This is the simplest form of select menu, with a static list of options passed in when defining the element.""" type = SlackElementType.multi_static_select @@ -164,7 +171,7 @@ class SlackMultiSelectMenuElement(SlackMultiSelectBaseElement): None, description="An array of option objects.", max_items=100 ) option_groups: List[SlackOptionGroup] = Field( - None, description="An array of option group objects" + None, description="An array of option group objects", max_items=100 ) @root_validator @@ -189,7 +196,7 @@ class SlackMultiSelectExternalMenuElement(SlackMultiSelectBaseElement): ) -class SlackMultiSelectUserList(SlackMultiSelectBaseElement): +class SlackMultiSelectUserListElement(SlackMultiSelectBaseElement): """This multi-select menu will populate its options with a list of Slack users visible to the current user in the active workspace.""" @@ -200,7 +207,7 @@ class SlackMultiSelectUserList(SlackMultiSelectBaseElement): ) -class SlackMultiSelectConversations(SlackMultiSelectBaseElement): +class SlackMultiSelectConversationsElement(SlackMultiSelectBaseElement): """This multi-select menu will populate its options with a list of public and private channels, DMs, and MPIMs visible to the current user in the active workspace""" @@ -211,7 +218,7 @@ class SlackMultiSelectConversations(SlackMultiSelectBaseElement): ) -class SlackMultiSelectChannels(SlackMultiSelectBaseElement): +class SlackMultiSelectChannelsElement(SlackMultiSelectBaseElement): """This multi-select menu will populate its options with a list of public channels visible to the current user in the active workspace""" @@ -222,7 +229,7 @@ class SlackMultiSelectChannels(SlackMultiSelectBaseElement): ) -class SlackOverflowElement(SlackBaseElementSchema): +class SlackOverflowElement(SlackBaseElement): """This is like a cross between a button and a select menu - when a user clicks on this overflow button, they will be presented with a list of options to choose from. Unlike the select menu, there is no typeahead field, and the button always appears with an ellipsis ("…") rather than customisable text.""" @@ -241,7 +248,7 @@ class SlackOverflowElement(SlackBaseElementSchema): ) -class SlackPlainTextInputElement(SlackBaseElementSchema): +class SlackPlainTextInputElement(SlackBaseElement): """A plain-text input, similar to the HTML tag, creates a field where a user can enter freeform data. It can appear as a single-line field or a larger textarea using the multiline flag.""" @@ -271,7 +278,7 @@ class SlackPlainTextInputElement(SlackBaseElementSchema): ) -class SlackRadioButtonGroupElement(SlackBaseElementSchema): +class SlackRadioButtonGroupElement(SlackBaseElement): """A radio button group that allows a user to choose one item from a list of possible options""" type = SlackElementType.radio_buttons @@ -288,17 +295,82 @@ class SlackRadioButtonGroupElement(SlackBaseElementSchema): ) -SlackElementTypes = Union[ +class SlackStaticSelectElement(SlackMultiStaticSelectMenuElement): + """This is the simplest form of select menu, with a static list of options passed in when defining the element""" + + type = SlackElementType.static_select + + +class SlackExternalSelectElement(SlackMultiSelectExternalMenuElement): + """This select menu will load its options from an external data source, allowing for a dynamic list of options""" + + type = SlackElementType.external_select + + +class SlackSelectConversationsElement(SlackMultiSelectConversationsElement): + """This select menu will populate its options with a list of public and private channels, + DMs, and MPIMs visible to the current user in the active workspace.""" + + type = SlackElementType.conversations_select + + +class SlackSelectChannelsElement(SlackMultiSelectChannelsElement): + """This select menu will populate its options with a list of public channels visible to the current user + in the active workspace.""" + + type = SlackElementType.channels_select + + +class SlackSelectUsersElement(SlackMultiSelectUserListElement): + """This select menu will populate its options with a list of Slack users visible to the + current user in the active workspace""" + + type = SlackElementType.users_select + + +SectionElements = Union[ SlackButtonElement, SlackCheckboxElement, SlackDatePickerElement, SlackImageElement, - SlackMultiSelectMenuElement, + SlackMultiStaticSelectMenuElement, SlackMultiSelectExternalMenuElement, - SlackMultiSelectUserList, - SlackMultiSelectConversations, - SlackMultiSelectChannels, + SlackMultiSelectUserListElement, + SlackMultiSelectConversationsElement, + SlackMultiSelectChannelsElement, + SlackOverflowElement, + SlackPlainTextInputElement, + SlackRadioButtonGroupElement, + SlackStaticSelectElement, + SlackExternalSelectElement, + SlackSelectUsersElement, + SlackSelectChannelsElement, +] +ActionsElements = Union[ + SlackButtonElement, + SlackCheckboxElement, + SlackDatePickerElement, SlackOverflowElement, SlackPlainTextInputElement, SlackRadioButtonGroupElement, + SlackStaticSelectElement, + SlackExternalSelectElement, + SlackSelectUsersElement, + SlackSelectChannelsElement, +] +InputElements = Union[ + SlackCheckboxElement, + SlackDatePickerElement, + SlackMultiStaticSelectMenuElement, + SlackMultiSelectExternalMenuElement, + SlackMultiSelectUserListElement, + SlackMultiSelectConversationsElement, + SlackMultiSelectChannelsElement, + SlackPlainTextInputElement, + SlackRadioButtonGroupElement, + SlackStaticSelectElement, + SlackExternalSelectElement, + SlackSelectUsersElement, + SlackSelectChannelsElement, ] +ContextElements = Union[SlackImageElement] From 71f90081a81e9bf06db032a683d60d464806b930 Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 1 Mar 2020 23:18:45 +0200 Subject: [PATCH 045/137] tweak --- notifiers/providers/slack/elements.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notifiers/providers/slack/elements.py b/notifiers/providers/slack/elements.py index ce4e427f..416eb76a 100644 --- a/notifiers/providers/slack/elements.py +++ b/notifiers/providers/slack/elements.py @@ -373,4 +373,4 @@ class SlackSelectUsersElement(SlackMultiSelectUserListElement): SlackSelectUsersElement, SlackSelectChannelsElement, ] -ContextElements = Union[SlackImageElement] +ContextElements = SlackImageElement From a8ed5d7a2be8d805be7a945d28f598a832fe0584 Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 1 Mar 2020 23:19:36 +0200 Subject: [PATCH 046/137] updated deps --- requirements.txt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 93c9ea12..2f9defae 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,4 @@ requests>=2.21.0 jsonschema>=3.0.0 click>=7.0 rfc3987>=1.3.8 -pendulum -pydantic -glom \ No newline at end of file +pydantic[email] \ No newline at end of file From b0face35ca2e8a7a2a360506983287cda2dd4e89 Mon Sep 17 00:00:00 2001 From: liiight Date: Mon, 2 Mar 2020 00:28:35 +0200 Subject: [PATCH 047/137] added all blocks --- notifiers/providers/slack/blocks.py | 85 +++++++++++++++++++++++---- notifiers/providers/slack/elements.py | 48 --------------- 2 files changed, 74 insertions(+), 59 deletions(-) diff --git a/notifiers/providers/slack/blocks.py b/notifiers/providers/slack/blocks.py index abfe1838..ae6e8a61 100644 --- a/notifiers/providers/slack/blocks.py +++ b/notifiers/providers/slack/blocks.py @@ -3,10 +3,16 @@ from pydantic import constr from pydantic import Field +from pydantic import HttpUrl +from pydantic import root_validator +from typing_extensions import Literal from notifiers.models.provider import SchemaModel -from notifiers.providers.slack.composition import SlackBlockTextObject -from notifiers.providers.slack.elements import SlackElementTypes +from notifiers.providers.slack.composition import _text_object_factory +from notifiers.providers.slack.composition import SlackTextType +from notifiers.providers.slack.elements import ActionsElements +from notifiers.providers.slack.elements import ContextElements +from notifiers.providers.slack.elements import SectionElements class SlackBlockType(Enum): @@ -31,21 +37,78 @@ class SlackBaseBlock(SchemaModel): class SlackSectionBlock(SlackBaseBlock): + """A section is one of the most flexible blocks available - it can be used as a simple text block, + in combination with text fields, or side-by-side with any of the available block elements""" + type = SlackBlockType.section - text: SlackBlockTextObject = Field( - None, - description="The text for the block, in the form of a text object." - " Maximum length for the text in this field is 3000 characters." - " This field is not required if a valid array of fields objects is provided instead", + text: _text_object_factory(max_length=3000) = Field( + None, description="The text for the block, in the form of a text object" ) - fields: List[SlackBlockTextObject] = Field( + fields: List[_text_object_factory(max_length=2000)] = Field( None, description="An array of text objects. Any text objects included with fields will be rendered in a compact " - "format that allows for 2 columns of side-by-side text. Maximum number of items is 10." - " Maximum length for the text in each item is 2000 characters", + "format that allows for 2 columns of side-by-side text", max_length=10, ) - accessory: SlackElementTypes = Field( + accessory: SectionElements = Field( None, description="One of the available element objects" ) + + @root_validator + def text_or_field(cls, values): + if not any(value in values for value in ("text", "fields")): + raise ValueError("Either 'text' or 'fields' are required") + return values + + +class SlackDividerBlock(SlackBaseBlock): + """A content divider, like an
, to split up different blocks inside of a message. + The divider block is nice and neat, requiring only a type.""" + + type = SlackBlockType.divider + + +class SlackImageBlock(SlackBaseBlock): + """A simple image block, designed to make those cat photos really pop""" + + type = SlackBlockType.image + image_url: HttpUrl = Field(..., description="The URL of the image to be displayed") + alt_text: constr(max_length=2000) = Field( + ..., + description="A plain-text summary of the image. This should not contain any markup", + ) + title: _text_object_factory( + type_=SlackTextType.plain_text, max_length=2000 + ) = Field(None, description="An optional title for the image") + + +class SlackActionsBlock(SlackBaseBlock): + """A block that is used to hold interactive elements""" + + type = SlackBlockType.actions + elements: ActionsElements = Field( + ..., + description="An array of interactive element objects - buttons, select menus, overflow menus, or date pickers", + max_length=5, + ) + + +class SlackContextBlock(SlackBaseBlock): + """Displays message context, which can include both images and text""" + + type = SlackBlockType.context + elements: ContextElements = Field( + ..., description="An array of image elements and text objects", max_items=10 + ) + + +class SlackFileBlock(SlackBaseBlock): + """Displays a remote file""" + + type = SlackBlockType.file + external_id: str = Field(..., description="The external unique ID for this file") + source: Literal["remote"] = Field( + "remote", + description="At the moment, source will always be remote for a remote file", + ) diff --git a/notifiers/providers/slack/elements.py b/notifiers/providers/slack/elements.py index 416eb76a..772b1630 100644 --- a/notifiers/providers/slack/elements.py +++ b/notifiers/providers/slack/elements.py @@ -3,7 +3,6 @@ from typing import List from typing import Union -from pydantic import conint from pydantic import constr from pydantic import Field from pydantic import HttpUrl @@ -248,36 +247,6 @@ class SlackOverflowElement(SlackBaseElement): ) -class SlackPlainTextInputElement(SlackBaseElement): - """A plain-text input, similar to the HTML tag, creates a field where a user can enter freeform data. - It can appear as a single-line field or a larger textarea using the multiline flag.""" - - type = SlackElementType.plain_text_input - placeholder: _text_object_factory( - type_=SlackTextType.plain_text, max_length=150 - ) = Field( - None, - description="A plain_text only text object that defines the placeholder text shown in the plain-text input", - ) - initial_value: str = Field( - None, description="The initial value in the plain-text input when it is loaded" - ) - multiline: bool = Field( - None, - description="Indicates whether the input will be a single line (false) or a larger textarea (true)", - ) - min_length: conint(gt=0, le=3000) = Field( - None, - description="The minimum length of input that the user must provide. If the user provides less," - " they will receive an error", - ) - max_length: PositiveInt = Field( - None, - description="The maximum length of input that the user can provide. If the user provides more," - " they will receive an error", - ) - - class SlackRadioButtonGroupElement(SlackBaseElement): """A radio button group that allows a user to choose one item from a list of possible options""" @@ -339,7 +308,6 @@ class SlackSelectUsersElement(SlackMultiSelectUserListElement): SlackMultiSelectConversationsElement, SlackMultiSelectChannelsElement, SlackOverflowElement, - SlackPlainTextInputElement, SlackRadioButtonGroupElement, SlackStaticSelectElement, SlackExternalSelectElement, @@ -351,22 +319,6 @@ class SlackSelectUsersElement(SlackMultiSelectUserListElement): SlackCheckboxElement, SlackDatePickerElement, SlackOverflowElement, - SlackPlainTextInputElement, - SlackRadioButtonGroupElement, - SlackStaticSelectElement, - SlackExternalSelectElement, - SlackSelectUsersElement, - SlackSelectChannelsElement, -] -InputElements = Union[ - SlackCheckboxElement, - SlackDatePickerElement, - SlackMultiStaticSelectMenuElement, - SlackMultiSelectExternalMenuElement, - SlackMultiSelectUserListElement, - SlackMultiSelectConversationsElement, - SlackMultiSelectChannelsElement, - SlackPlainTextInputElement, SlackRadioButtonGroupElement, SlackStaticSelectElement, SlackExternalSelectElement, From c4b29607e3082c8b5969c066b30f3814d2438fbb Mon Sep 17 00:00:00 2001 From: liiight Date: Mon, 2 Mar 2020 00:31:38 +0200 Subject: [PATCH 048/137] removed redundant fields --- notifiers/providers/slack/main.py | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/notifiers/providers/slack/main.py b/notifiers/providers/slack/main.py index 6a3e0d1c..4a75547e 100644 --- a/notifiers/providers/slack/main.py +++ b/notifiers/providers/slack/main.py @@ -3,7 +3,6 @@ from pydantic import Field from pydantic import HttpUrl -from pydantic import validator from notifiers.models.provider import Provider from notifiers.models.provider import SchemaModel @@ -49,19 +48,6 @@ class SlackSchema(SchemaModel): " Defaults to true", alias="mrkdwn", ) - icon_url: HttpUrl = Field(None, description="Override bot icon with image URL") - icon_emoji: str = Field(None, description="Override bot icon with emoji name") - username: str = Field(None, description="Override the displayed bot name") - channel: str = Field( - None, description="Override default channel or private message" - ) - unfurl_links: bool = Field( - None, description="Avoid or enable automatic attachment creation from URLs" - ) - - @validator("icon_emoji") - def emoji(cls, v: str): - return f':{v.strip(":")}:' class Slack(Provider): From 757a406c16e029114468fb101dcf47701572f9fc Mon Sep 17 00:00:00 2001 From: liiight Date: Tue, 3 Mar 2020 10:50:10 +0200 Subject: [PATCH 049/137] finished slack schema --- notifiers/providers/slack/composition.py | 6 + notifiers/providers/slack/main.py | 258 ++++++++++++----------- 2 files changed, 143 insertions(+), 121 deletions(-) diff --git a/notifiers/providers/slack/composition.py b/notifiers/providers/slack/composition.py index 20e7e514..ed729c76 100644 --- a/notifiers/providers/slack/composition.py +++ b/notifiers/providers/slack/composition.py @@ -107,3 +107,9 @@ class SlackConfirmationDialog(SchemaModel): text: _text_object_factory(max_length=300) confirm: _text_object_factory(max_length=30) deny: _text_object_factory(type_=SlackTextType.plain_text, max_length=30) + + +class SlackColor(Enum): + good = "good" + warning = "warning" + danger = "danger" diff --git a/notifiers/providers/slack/main.py b/notifiers/providers/slack/main.py index 4a75547e..3033ecaf 100644 --- a/notifiers/providers/slack/main.py +++ b/notifiers/providers/slack/main.py @@ -1,21 +1,157 @@ +from datetime import datetime from typing import List from typing import Union +from pydantic import constr from pydantic import Field from pydantic import HttpUrl +from pydantic import root_validator +from pydantic import validator +from pydantic.color import Color as ColorType from notifiers.models.provider import Provider from notifiers.models.provider import SchemaModel from notifiers.models.response import Response from notifiers.providers.slack.blocks import SlackSectionBlock +from notifiers.providers.slack.composition import SlackColor from notifiers.utils import requests +class SlackFieldObject(SchemaModel): + title: str = Field( + None, + description="Shown as a bold heading displayed in the field object." + " It cannot contain markup and will be escaped for you", + ) + value: str = Field( + None, + description="The text value displayed in the field object. " + "It can be formatted as plain text, or with mrkdwn by using the mrkdwn_in", + ) + short: bool = Field( + None, + description="Indicates whether the field object is short enough to be " + "displayed side-by-side with other field objects", + ) + + class SlackAttachmentSchema(SchemaModel): - pass + """Secondary content can be attached to messages to include lower priority content - content that + doesn't necessarily need to be seen to appreciate the intent of the message, + but perhaps adds further context or additional information.""" + + blocks: List[Union[SlackSectionBlock]] = Field( + None, + description="An array of layout blocks in the same format as described in the building blocks guide.", + max_length=50, + ) + color: Union[SlackColor, ColorType] = Field( + None, + description="Changes the color of the border on the left side of this attachment from the default gray", + ) + author_icon: HttpUrl = Field( + None, + description="A valid URL that displays a small 16px by 16px image to the left of the author_name text." + " Will only work if author_name is present", + ) + author_link: HttpUrl = Field( + None, + description="A valid URL that will hyperlink the author_name text. Will only work if author_name is present.", + ) + author_name: str = Field( + None, description="Small text used to display the author's name" + ) + fallback: str = Field( + None, + description="A plain text summary of the attachment used in clients that don't show " + "formatted text (eg. IRC, mobile notifications)", + ) + fields: List[SlackFieldObject] = Field( + None, + description="An array of field objects that get displayed in a table-like way." + " For best results, include no more than 2-3 field objects", + min_items=1, + ) + footer: constr(max_length=300) = Field( + None, + description="Some brief text to help contextualize and identify an attachment." + " Limited to 300 characters, and may be truncated further when displayed to users in " + "environments with limited screen real estate", + ) + footer_icon: HttpUrl = Field( + None, + description="A valid URL to an image file that will be displayed beside the footer text. " + "Will only work if author_name is present. We'll render what you provide at 16px by 16px. " + "It's best to use an image that is similarly sized", + ) + image_url: HttpUrl = Field( + None, + description="A valid URL to an image file that will be displayed at the bottom of the attachment." + " We support GIF, JPEG, PNG, and BMP formats. " + "Large images will be resized to a maximum width of 360px or a maximum height of 500px," + " while still maintaining the original aspect ratio. Cannot be used with thumb_url", + ) + markdown_in: List[str] = Field( + None, + description="An array of field names that should be formatted by markdown syntax", + alias="mrkdwn_in", + ) + pretext: str = Field( + None, + description="Text that appears above the message attachment block. " + "It can be formatted as plain text, or with mrkdwn by including it in the mrkdwn_in field", + ) + text: str = Field( + None, + description="The main body text of the attachment. It can be formatted as plain text, " + "or with mrkdwn by including it in the mrkdwn_in field." + " The content will automatically collapse if it contains 700+ characters or 5+ linebreaks," + ' and will display a "Show more..." link to expand the content', + ) + thumb_url: HttpUrl = Field( + None, + description="A valid URL to an image file that will be displayed as a thumbnail on the right side " + "of a message attachment. We currently support the following formats: GIF, JPEG, PNG," + " and BMP. The thumbnail's longest dimension will be scaled down to 75px while maintaining " + "the aspect ratio of the image. The filesize of the image must also be less than 500 KB." + " For best results, please use images that are already 75px by 75px", + ) + title: str = Field( + None, description="Large title text near the top of the attachment" + ) + title_link: HttpUrl = Field( + None, description="A valid URL that turns the title text into a hyperlink" + ) + timestamp: datetime = Field( + None, + description="A datetime that is used to related your attachment to a specific time." + " The attachment will display the additional timestamp value as part of the attachment's footer. " + "Your message's timestamp will be displayed in varying ways, depending on how far in the past " + "or future it is, relative to the present. Form factors, like mobile versus desktop may " + "also transform its rendered appearance", + alias="ts", + ) + + @validator("color") + def color_format(cls, v: Union[SlackColor, ColorType]): + return v.as_hex() if isinstance(v, ColorType) else v.value + + @validator("timestamp") + def timestamp_format(cls, v: datetime): + return v.timestamp() + + @root_validator + def check_values(cls, values): + if "blocks" not in values and not any( + value in values for value in ("fallback", "text") + ): + raise ValueError("Either 'blocks' or 'fallback' or 'text' are required") + return values class SlackSchema(SchemaModel): + """Slack's webhook schema""" + webhook_url: HttpUrl = Field( ..., description="The webhook URL to use. Register one at https://my.slack.com/services/new/incoming-webhook/", @@ -59,126 +195,6 @@ class Slack(Provider): schema_model = SlackSchema - __fields = { - "type": "array", - "title": "Fields are displayed in a table on the message", - "minItems": 1, - "items": { - "type": "object", - "properties": { - "title": {"type": "string", "title": "Required Field Title"}, - "value": { - "type": "string", - "title": "Text value of the field. May contain standard message markup and must" - " be escaped as normal. May be multi-line", - }, - "short": { - "type": "boolean", - "title": "Optional flag indicating whether the `value` is short enough to be displayed" - " side-by-side with other values", - }, - }, - "required": ["title"], - "additionalProperties": False, - }, - } - __attachments = { - "type": "array", - "items": { - "type": "object", - "properties": { - "title": {"type": "string", "title": "Attachment title"}, - "author_name": { - "type": "string", - "title": "Small text used to display the author's name", - }, - "author_link": { - "type": "string", - "title": "A valid URL that will hyperlink the author_name text mentioned above. " - "Will only work if author_name is present", - }, - "author_icon": { - "type": "string", - "title": "A valid URL that displays a small 16x16px image to the left of the author_name text. " - "Will only work if author_name is present", - }, - "title_link": {"type": "string", "title": "Attachment title URL"}, - "image_url": {"type": "string", "format": "uri", "title": "Image URL"}, - "thumb_url": { - "type": "string", - "format": "uri", - "title": "Thumbnail URL", - }, - "footer": {"type": "string", "title": "Footer text"}, - "footer_icon": { - "type": "string", - "format": "uri", - "title": "Footer icon URL", - }, - "ts": { - "type": ["integer", "string"], - "format": "timestamp", - "title": "Provided timestamp (epoch)", - }, - "fallback": { - "type": "string", - "title": "A plain-text summary of the attachment. This text will be used in clients that don't" - " show formatted text (eg. IRC, mobile notifications) and should not contain any markup.", - }, - "text": { - "type": "string", - "title": "Optional text that should appear within the attachment", - }, - "pretext": { - "type": "string", - "title": "Optional text that should appear above the formatted data", - }, - "color": { - "type": "string", - "title": "Can either be one of 'good', 'warning', 'danger', or any hex color code", - }, - "fields": __fields, - }, - "required": ["fallback"], - "additionalProperties": False, - }, - } - _required = {"required": ["webhook_url", "message"]} - _schema = { - "type": "object", - "properties": { - "webhook_url": { - "type": "string", - "format": "uri", - "title": "the webhook URL to use. Register one at https://my.slack.com/services/new/incoming-webhook/", - }, - "icon_url": { - "type": "string", - "format": "uri", - "title": "override bot icon with image URL", - }, - "icon_emoji": { - "type": "string", - "title": "override bot icon with emoji name.", - }, - "username": {"type": "string", "title": "override the displayed bot name"}, - "channel": { - "type": "string", - "title": "override default channel or private message", - }, - "unfurl_links": { - "type": "boolean", - "title": "avoid automatic attachment creation from URLs", - }, - "message": { - "type": "string", - "title": "This is the text that will be posted to the channel", - }, - "attachments": __attachments, - }, - "additionalProperties": False, - } - def _send_notification(self, data: dict) -> Response: url = data.pop("webhook_url") response, errors = requests.post(url, json=data) From 761e77bd8e10706e33cccac27fe73ae779eb6c05 Mon Sep 17 00:00:00 2001 From: liiight Date: Tue, 3 Mar 2020 11:27:18 +0200 Subject: [PATCH 050/137] fixed schema issues --- notifiers/providers/__init__.py | 4 ++-- notifiers/providers/slack/blocks.py | 17 +++++++++------- notifiers/providers/slack/composition.py | 26 ++++++++++++++++-------- notifiers/providers/slack/elements.py | 8 +++++--- notifiers/providers/slack/main.py | 7 ++++--- 5 files changed, 38 insertions(+), 24 deletions(-) diff --git a/notifiers/providers/__init__.py b/notifiers/providers/__init__.py index b2743b4f..1c54d69d 100644 --- a/notifiers/providers/__init__.py +++ b/notifiers/providers/__init__.py @@ -9,16 +9,16 @@ from . import pushbullet from . import pushover from . import simplepush -from . import slack_ from . import statuspage from . import telegram from . import twilio from . import zulip +from .slack import Slack _all_providers = { # "pushover": pushover.Pushover, # "simplepush": simplepush.SimplePush, - # "slack": slack.Slack, + "slack": Slack, "email": email.SMTP, "gmail": gmail.Gmail, # "telegram": telegram.Telegram, diff --git a/notifiers/providers/slack/blocks.py b/notifiers/providers/slack/blocks.py index ae6e8a61..f6f041c3 100644 --- a/notifiers/providers/slack/blocks.py +++ b/notifiers/providers/slack/blocks.py @@ -41,15 +41,18 @@ class SlackSectionBlock(SlackBaseBlock): in combination with text fields, or side-by-side with any of the available block elements""" type = SlackBlockType.section - text: _text_object_factory(max_length=3000) = Field( + text: _text_object_factory("SectionBlockText", max_length=3000) = Field( None, description="The text for the block, in the form of a text object" ) - fields: List[_text_object_factory(max_length=2000)] = Field( + block_fields: List[ + _text_object_factory("SectionBlockFieldText", max_length=2000) + ] = Field( None, description="An array of text objects. Any text objects included with fields will be rendered in a compact " "format that allows for 2 columns of side-by-side text", - max_length=10, + max_items=10, + alias="fields", ) accessory: SectionElements = Field( None, description="One of the available element objects" @@ -79,7 +82,7 @@ class SlackImageBlock(SlackBaseBlock): description="A plain-text summary of the image. This should not contain any markup", ) title: _text_object_factory( - type_=SlackTextType.plain_text, max_length=2000 + "ImageText", type_=SlackTextType.plain_text, max_length=2000 ) = Field(None, description="An optional title for the image") @@ -87,10 +90,10 @@ class SlackActionsBlock(SlackBaseBlock): """A block that is used to hold interactive elements""" type = SlackBlockType.actions - elements: ActionsElements = Field( + elements: List[ActionsElements] = Field( ..., description="An array of interactive element objects - buttons, select menus, overflow menus, or date pickers", - max_length=5, + max_items=5, ) @@ -98,7 +101,7 @@ class SlackContextBlock(SlackBaseBlock): """Displays message context, which can include both images and text""" type = SlackBlockType.context - elements: ContextElements = Field( + elements: List[ContextElements] = Field( ..., description="An array of image elements and text objects", max_items=10 ) diff --git a/notifiers/providers/slack/composition.py b/notifiers/providers/slack/composition.py index ed729c76..b7ecf316 100644 --- a/notifiers/providers/slack/composition.py +++ b/notifiers/providers/slack/composition.py @@ -44,12 +44,12 @@ class Config: def _text_object_factory( - max_length: int, type_: SlackTextType = None + model_name: str, max_length: int, type_: SlackTextType = None ) -> Type[SlackBlockTextObject]: """Returns a custom text object schema""" type_value = (SlackTextType, type_) if type_ else (SlackTextType, ...) return create_model( - "CustomTextObject", + model_name, type=type_value, text=(constr(max_length=max_length), ...), __base__=SlackBlockTextObject, @@ -60,7 +60,9 @@ class SlackOption(SchemaModel): """An object that represents a single selectable item in a select menu, multi-select menu, radio button group, or overflow menu.""" - text: _text_object_factory(type_=SlackTextType.plain_text, max_length=75) = Field( + text: _text_object_factory( + "OptionText", type_=SlackTextType.plain_text, max_length=75 + ) = Field( ..., description="A plain_text only text object that defines the text shown in the option on the menu." " Maximum length for the text in this field is 75 characters", @@ -70,7 +72,7 @@ class SlackOption(SchemaModel): description="The string value that will be passed to your app when this option is chosen", ) description: _text_object_factory( - type_=SlackTextType.plain_text, max_length=75 + "DescriptionText", type_=SlackTextType.plain_text, max_length=75 ) = Field( None, description="A plain_text only text object that defines a line of descriptive text shown below the " @@ -88,7 +90,9 @@ class SlackOption(SchemaModel): class SlackOptionGroup(SchemaModel): """Provides a way to group options in a select menu or multi-select menu""" - label: _text_object_factory(type_=SlackTextType.plain_text, max_length=75) = Field( + label: _text_object_factory( + "OptionGroupText", type_=SlackTextType.plain_text, max_length=75 + ) = Field( ..., description="A plain_text only text object that defines the label shown above this group of options", ) @@ -103,10 +107,14 @@ class SlackConfirmationDialog(SchemaModel): """An object that defines a dialog that provides a confirmation step to any interactive element. This dialog will ask the user to confirm their action by offering a confirm and deny buttons.""" - title: _text_object_factory(type_=SlackTextType.plain_text, max_length=100) - text: _text_object_factory(max_length=300) - confirm: _text_object_factory(max_length=30) - deny: _text_object_factory(type_=SlackTextType.plain_text, max_length=30) + title: _text_object_factory( + "DialogTitleText", type_=SlackTextType.plain_text, max_length=100 + ) + text: _text_object_factory("DialogTextText", max_length=300) + confirm: _text_object_factory("DialogConfirmText", max_length=30) + deny: _text_object_factory( + "DialogDenyText", type_=SlackTextType.plain_text, max_length=30 + ) class SlackColor(Enum): diff --git a/notifiers/providers/slack/elements.py b/notifiers/providers/slack/elements.py index 772b1630..99d10ec3 100644 --- a/notifiers/providers/slack/elements.py +++ b/notifiers/providers/slack/elements.py @@ -64,7 +64,9 @@ class SlackButtonElement(SlackBaseElement): The button can be a trigger for anything from opening a simple link to starting a complex workflow.""" type = SlackElementType.button - text: _text_object_factory(type_=SlackTextType.plain_text, max_length=75) + text: _text_object_factory( + "ElementText", type_=SlackTextType.plain_text, max_length=75 + ) url: HttpUrl = Field( None, description="A URL to load in the user's browser when the button is clicked. " @@ -107,7 +109,7 @@ class SlackDatePickerElement(SlackBaseElement): """An element which lets users easily select a date from a calendar style UI.""" placeholder: _text_object_factory( - type_=SlackTextType.plain_text, max_length=150 + "DatePicketText", type_=SlackTextType.plain_text, max_length=150 ) = Field( None, description="A plain_text only text object that defines the placeholder text shown on the datepicker." @@ -140,7 +142,7 @@ class SlackImageElement(SlackBaseElement): class SlackMultiSelectBaseElement(SlackBaseElement): placeholder: _text_object_factory( - type_=SlackTextType.plain_text, max_length=150 + "MultiSelectText", type_=SlackTextType.plain_text, max_length=150 ) = Field( ..., description="A plain_text only text object that defines the placeholder text shown on the menu", diff --git a/notifiers/providers/slack/main.py b/notifiers/providers/slack/main.py index 3033ecaf..759eb527 100644 --- a/notifiers/providers/slack/main.py +++ b/notifiers/providers/slack/main.py @@ -43,7 +43,7 @@ class SlackAttachmentSchema(SchemaModel): blocks: List[Union[SlackSectionBlock]] = Field( None, description="An array of layout blocks in the same format as described in the building blocks guide.", - max_length=50, + max_items=50, ) color: Union[SlackColor, ColorType] = Field( None, @@ -66,11 +66,12 @@ class SlackAttachmentSchema(SchemaModel): description="A plain text summary of the attachment used in clients that don't show " "formatted text (eg. IRC, mobile notifications)", ) - fields: List[SlackFieldObject] = Field( + attachment_fields: List[SlackFieldObject] = Field( None, description="An array of field objects that get displayed in a table-like way." " For best results, include no more than 2-3 field objects", min_items=1, + alias="fields", ) footer: constr(max_length=300) = Field( None, @@ -169,7 +170,7 @@ class SlackSchema(SchemaModel): blocks: List[Union[SlackSectionBlock]] = Field( None, description="An array of layout blocks in the same format as described in the building blocks guide.", - max_length=50, + max_items=50, ) attachments: List[SlackAttachmentSchema] = Field( None, From 34647fac6bb50df9a9589f324ce895849ad3e630 Mon Sep 17 00:00:00 2001 From: liiight Date: Tue, 3 Mar 2020 11:33:19 +0200 Subject: [PATCH 051/137] fixed tests --- notifiers/providers/slack/main.py | 3 ++- tests/providers/test_slack.py | 7 ++----- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/notifiers/providers/slack/main.py b/notifiers/providers/slack/main.py index 759eb527..0af201e5 100644 --- a/notifiers/providers/slack/main.py +++ b/notifiers/providers/slack/main.py @@ -196,7 +196,8 @@ class Slack(Provider): schema_model = SlackSchema - def _send_notification(self, data: dict) -> Response: + def _send_notification(self, data: SlackSchema) -> Response: + data = data.to_dict() url = data.pop("webhook_url") response, errors = requests.post(url, json=data) return self.create_response(data, response, errors) diff --git a/tests/providers/test_slack.py b/tests/providers/test_slack.py index 84f9d1b5..c4f4faeb 100644 --- a/tests/providers/test_slack.py +++ b/tests/providers/test_slack.py @@ -25,11 +25,9 @@ def test_sanity(self, provider, test_message): @pytest.mark.online def test_all_options(self, provider): + # todo add all blocks tests data = { "message": "http://foo.com", - "icon_emoji": "poop", - "username": "test", - "channel": "test", "attachments": [ { "title": "attachment 1", @@ -75,5 +73,4 @@ def test_all_options(self, provider): }, ], } - rsp = provider.notify(**data) - rsp.raise_on_errors() + provider.notify(**data, raise_on_errors=True) From 7b42d2709d259250fa84d83ce4a5690b28a2ae00 Mon Sep 17 00:00:00 2001 From: liiight Date: Tue, 3 Mar 2020 12:01:57 +0200 Subject: [PATCH 052/137] added check for emoji --- notifiers/providers/slack/composition.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/notifiers/providers/slack/composition.py b/notifiers/providers/slack/composition.py index b7ecf316..a4d7d6a8 100644 --- a/notifiers/providers/slack/composition.py +++ b/notifiers/providers/slack/composition.py @@ -6,6 +6,7 @@ from pydantic import create_model from pydantic import Field from pydantic import HttpUrl +from pydantic import root_validator from notifiers.models.provider import SchemaModel @@ -39,6 +40,12 @@ class SlackBlockTextObject(SchemaModel): " include manual parsing strings. This field is only usable when type is mrkdwn.", ) + @root_validator + def check_emoji(cls, values): + if values.get("emoji") and values["type"] is not SlackTextType.plain_text: + raise ValueError("Cannot use 'emoji' when type is not 'plain_text'") + return values + class Config: json_encoders = {SlackTextType: lambda v: v.value} From ef3e9ff35cf430150902ee09775522faea29c82b Mon Sep 17 00:00:00 2001 From: liiight Date: Tue, 3 Mar 2020 12:25:51 +0200 Subject: [PATCH 053/137] made dynamic text object schema more strict --- notifiers/providers/slack/composition.py | 6 ++++-- notifiers/providers/slack/elements.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/notifiers/providers/slack/composition.py b/notifiers/providers/slack/composition.py index a4d7d6a8..ad14a150 100644 --- a/notifiers/providers/slack/composition.py +++ b/notifiers/providers/slack/composition.py @@ -7,6 +7,7 @@ from pydantic import Field from pydantic import HttpUrl from pydantic import root_validator +from typing_extensions import Literal from notifiers.models.provider import SchemaModel @@ -53,8 +54,9 @@ class Config: def _text_object_factory( model_name: str, max_length: int, type_: SlackTextType = None ) -> Type[SlackBlockTextObject]: - """Returns a custom text object schema""" - type_value = (SlackTextType, type_) if type_ else (SlackTextType, ...) + """Returns a custom text object schema. If a `type_` is passed, + it's enforced as the only possible value (both the enum and its value) and set as the default""" + type_value = (Literal[type_, type_.value], type_) if type_ else (SlackTextType, ...) return create_model( model_name, type=type_value, diff --git a/notifiers/providers/slack/elements.py b/notifiers/providers/slack/elements.py index 99d10ec3..23985ec9 100644 --- a/notifiers/providers/slack/elements.py +++ b/notifiers/providers/slack/elements.py @@ -66,7 +66,7 @@ class SlackButtonElement(SlackBaseElement): type = SlackElementType.button text: _text_object_factory( "ElementText", type_=SlackTextType.plain_text, max_length=75 - ) + ) = Field(..., description="A text object that defines the button's text") url: HttpUrl = Field( None, description="A URL to load in the user's browser when the button is clicked. " From 2fb523141f4e8a44ba665aa6dd06aa09e23d6e78 Mon Sep 17 00:00:00 2001 From: liiight Date: Tue, 3 Mar 2020 17:43:11 +0200 Subject: [PATCH 054/137] fixed few schema bugs --- notifiers/providers/slack/blocks.py | 13 ++++++++++++- notifiers/providers/slack/composition.py | 19 +++++++++++-------- notifiers/providers/slack/elements.py | 12 ++++++------ notifiers/providers/slack/main.py | 6 +++--- 4 files changed, 32 insertions(+), 18 deletions(-) diff --git a/notifiers/providers/slack/blocks.py b/notifiers/providers/slack/blocks.py index f6f041c3..eb5cc419 100644 --- a/notifiers/providers/slack/blocks.py +++ b/notifiers/providers/slack/blocks.py @@ -1,5 +1,6 @@ from enum import Enum from typing import List +from typing import Union from pydantic import constr from pydantic import Field @@ -82,7 +83,7 @@ class SlackImageBlock(SlackBaseBlock): description="A plain-text summary of the image. This should not contain any markup", ) title: _text_object_factory( - "ImageText", type_=SlackTextType.plain_text, max_length=2000 + "ImageText", max_length=2000, type=SlackTextType.plain_text ) = Field(None, description="An optional title for the image") @@ -115,3 +116,13 @@ class SlackFileBlock(SlackBaseBlock): "remote", description="At the moment, source will always be remote for a remote file", ) + + +Blocks = Union[ + SlackSectionBlock, + SlackDividerBlock, + SlackImageBlock, + SlackActionsBlock, + SlackContextBlock, + SlackFileBlock, +] diff --git a/notifiers/providers/slack/composition.py b/notifiers/providers/slack/composition.py index ad14a150..02dc657b 100644 --- a/notifiers/providers/slack/composition.py +++ b/notifiers/providers/slack/composition.py @@ -43,7 +43,10 @@ class SlackBlockTextObject(SchemaModel): @root_validator def check_emoji(cls, values): - if values.get("emoji") and values["type"] is not SlackTextType.plain_text: + if ( + values.get("emoji") + and SlackTextType(values["type"]) is not SlackTextType.plain_text + ): raise ValueError("Cannot use 'emoji' when type is not 'plain_text'") return values @@ -52,11 +55,11 @@ class Config: def _text_object_factory( - model_name: str, max_length: int, type_: SlackTextType = None + model_name: str, max_length: int, type: SlackTextType = None ) -> Type[SlackBlockTextObject]: """Returns a custom text object schema. If a `type_` is passed, it's enforced as the only possible value (both the enum and its value) and set as the default""" - type_value = (Literal[type_, type_.value], type_) if type_ else (SlackTextType, ...) + type_value = (Literal[type, type.value], type) if type else (SlackTextType, ...) return create_model( model_name, type=type_value, @@ -70,7 +73,7 @@ class SlackOption(SchemaModel): or overflow menu.""" text: _text_object_factory( - "OptionText", type_=SlackTextType.plain_text, max_length=75 + "OptionText", max_length=75, type=SlackTextType.plain_text ) = Field( ..., description="A plain_text only text object that defines the text shown in the option on the menu." @@ -81,7 +84,7 @@ class SlackOption(SchemaModel): description="The string value that will be passed to your app when this option is chosen", ) description: _text_object_factory( - "DescriptionText", type_=SlackTextType.plain_text, max_length=75 + "DescriptionText", max_length=75, type=SlackTextType.plain_text ) = Field( None, description="A plain_text only text object that defines a line of descriptive text shown below the " @@ -100,7 +103,7 @@ class SlackOptionGroup(SchemaModel): """Provides a way to group options in a select menu or multi-select menu""" label: _text_object_factory( - "OptionGroupText", type_=SlackTextType.plain_text, max_length=75 + "OptionGroupText", max_length=75, type=SlackTextType.plain_text ) = Field( ..., description="A plain_text only text object that defines the label shown above this group of options", @@ -117,12 +120,12 @@ class SlackConfirmationDialog(SchemaModel): This dialog will ask the user to confirm their action by offering a confirm and deny buttons.""" title: _text_object_factory( - "DialogTitleText", type_=SlackTextType.plain_text, max_length=100 + "DialogTitleText", max_length=100, type=SlackTextType.plain_text ) text: _text_object_factory("DialogTextText", max_length=300) confirm: _text_object_factory("DialogConfirmText", max_length=30) deny: _text_object_factory( - "DialogDenyText", type_=SlackTextType.plain_text, max_length=30 + "DialogDenyText", max_length=30, type=SlackTextType.plain_text ) diff --git a/notifiers/providers/slack/elements.py b/notifiers/providers/slack/elements.py index 23985ec9..2085ebc3 100644 --- a/notifiers/providers/slack/elements.py +++ b/notifiers/providers/slack/elements.py @@ -43,7 +43,7 @@ class SlackElementType(Enum): class SlackBaseElement(SchemaModel): type: SlackElementType = Field(..., description="The type of element") action_id: constr(max_length=255) = Field( - ..., + None, description="An identifier for this action. You can use this when you receive an interaction payload to " "identify the source of the action. Should be unique among all other action_ids used " "elsewhere by your app", @@ -64,9 +64,9 @@ class SlackButtonElement(SlackBaseElement): The button can be a trigger for anything from opening a simple link to starting a complex workflow.""" type = SlackElementType.button - text: _text_object_factory( - "ElementText", type_=SlackTextType.plain_text, max_length=75 - ) = Field(..., description="A text object that defines the button's text") + text: _text_object_factory("ElementText", max_length=75) = Field( + ..., description="A text object that defines the button's text" + ) url: HttpUrl = Field( None, description="A URL to load in the user's browser when the button is clicked. " @@ -109,7 +109,7 @@ class SlackDatePickerElement(SlackBaseElement): """An element which lets users easily select a date from a calendar style UI.""" placeholder: _text_object_factory( - "DatePicketText", type_=SlackTextType.plain_text, max_length=150 + "DatePicketText", max_length=150, type=SlackTextType.plain_text ) = Field( None, description="A plain_text only text object that defines the placeholder text shown on the datepicker." @@ -142,7 +142,7 @@ class SlackImageElement(SlackBaseElement): class SlackMultiSelectBaseElement(SlackBaseElement): placeholder: _text_object_factory( - "MultiSelectText", type_=SlackTextType.plain_text, max_length=150 + "MultiSelectText", max_length=150, type=SlackTextType.plain_text ) = Field( ..., description="A plain_text only text object that defines the placeholder text shown on the menu", diff --git a/notifiers/providers/slack/main.py b/notifiers/providers/slack/main.py index 0af201e5..d344d096 100644 --- a/notifiers/providers/slack/main.py +++ b/notifiers/providers/slack/main.py @@ -12,7 +12,7 @@ from notifiers.models.provider import Provider from notifiers.models.provider import SchemaModel from notifiers.models.response import Response -from notifiers.providers.slack.blocks import SlackSectionBlock +from notifiers.providers.slack.blocks import Blocks from notifiers.providers.slack.composition import SlackColor from notifiers.utils import requests @@ -40,7 +40,7 @@ class SlackAttachmentSchema(SchemaModel): doesn't necessarily need to be seen to appreciate the intent of the message, but perhaps adds further context or additional information.""" - blocks: List[Union[SlackSectionBlock]] = Field( + blocks: List[Blocks] = Field( None, description="An array of layout blocks in the same format as described in the building blocks guide.", max_items=50, @@ -167,7 +167,7 @@ class SlackSchema(SchemaModel): "however it is highly recommended that you include it as the aforementioned fallback.", alias="text", ) - blocks: List[Union[SlackSectionBlock]] = Field( + blocks: List[Blocks] = Field( None, description="An array of layout blocks in the same format as described in the building blocks guide.", max_items=50, From 6de0437dd4a95ad85b77a799088580d97888f548 Mon Sep 17 00:00:00 2001 From: liiight Date: Tue, 3 Mar 2020 18:09:41 +0200 Subject: [PATCH 055/137] fixed context schema --- notifiers/providers/slack/blocks.py | 29 +++++++++++++++++++++------ notifiers/providers/slack/elements.py | 3 ++- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/notifiers/providers/slack/blocks.py b/notifiers/providers/slack/blocks.py index eb5cc419..7c62dc1b 100644 --- a/notifiers/providers/slack/blocks.py +++ b/notifiers/providers/slack/blocks.py @@ -41,7 +41,10 @@ class SlackSectionBlock(SlackBaseBlock): """A section is one of the most flexible blocks available - it can be used as a simple text block, in combination with text fields, or side-by-side with any of the available block elements""" - type = SlackBlockType.section + type: Literal[SlackBlockType.section, SlackBlockType.section.value] = Field( + ..., + description="The type of block. For a section block, type will always be section", + ) text: _text_object_factory("SectionBlockText", max_length=3000) = Field( None, description="The text for the block, in the form of a text object" ) @@ -70,13 +73,19 @@ class SlackDividerBlock(SlackBaseBlock): """A content divider, like an
, to split up different blocks inside of a message. The divider block is nice and neat, requiring only a type.""" - type = SlackBlockType.divider + type: Literal[SlackBlockType.divider, SlackBlockType.divider.value] = Field( + ..., + description="The type of block. For a divider block, type will always be divider", + ) class SlackImageBlock(SlackBaseBlock): """A simple image block, designed to make those cat photos really pop""" - type = SlackBlockType.image + type: Literal[SlackBlockType.image, SlackBlockType.image.value] = Field( + ..., + description="The type of block. For a image block, type will always be image", + ) image_url: HttpUrl = Field(..., description="The URL of the image to be displayed") alt_text: constr(max_length=2000) = Field( ..., @@ -90,7 +99,10 @@ class SlackImageBlock(SlackBaseBlock): class SlackActionsBlock(SlackBaseBlock): """A block that is used to hold interactive elements""" - type = SlackBlockType.actions + type: Literal[SlackBlockType.actions, SlackBlockType.actions.value] = Field( + ..., + description="The type of block. For an actions block, type will always be actions", + ) elements: List[ActionsElements] = Field( ..., description="An array of interactive element objects - buttons, select menus, overflow menus, or date pickers", @@ -101,7 +113,10 @@ class SlackActionsBlock(SlackBaseBlock): class SlackContextBlock(SlackBaseBlock): """Displays message context, which can include both images and text""" - type = SlackBlockType.context + type: Literal[SlackBlockType.context, SlackBlockType.context.value] = Field( + ..., + description="The type of block. For a context block, type will always be context", + ) elements: List[ContextElements] = Field( ..., description="An array of image elements and text objects", max_items=10 ) @@ -110,7 +125,9 @@ class SlackContextBlock(SlackBaseBlock): class SlackFileBlock(SlackBaseBlock): """Displays a remote file""" - type = SlackBlockType.file + type: Literal[SlackBlockType.file, SlackBlockType.file.value] = Field( + ..., description="The type of block. For a file block, type will always be file" + ) external_id: str = Field(..., description="The external unique ID for this file") source: Literal["remote"] = Field( "remote", diff --git a/notifiers/providers/slack/elements.py b/notifiers/providers/slack/elements.py index 2085ebc3..148559e4 100644 --- a/notifiers/providers/slack/elements.py +++ b/notifiers/providers/slack/elements.py @@ -12,6 +12,7 @@ from notifiers.models.provider import SchemaModel from notifiers.providers.slack.composition import _text_object_factory +from notifiers.providers.slack.composition import SlackBlockTextObject from notifiers.providers.slack.composition import SlackConfirmationDialog from notifiers.providers.slack.composition import SlackOption from notifiers.providers.slack.composition import SlackOptionGroup @@ -327,4 +328,4 @@ class SlackSelectUsersElement(SlackMultiSelectUserListElement): SlackSelectUsersElement, SlackSelectChannelsElement, ] -ContextElements = SlackImageElement +ContextElements = Union[SlackImageElement, SlackBlockTextObject] From 4473716f3a3e7af2b1c54fbb6d7500815f3428ff Mon Sep 17 00:00:00 2001 From: liiight Date: Tue, 3 Mar 2020 18:10:05 +0200 Subject: [PATCH 056/137] fixed context schema --- notifiers/providers/slack/blocks.py | 1 - 1 file changed, 1 deletion(-) diff --git a/notifiers/providers/slack/blocks.py b/notifiers/providers/slack/blocks.py index 7c62dc1b..ccaaa78c 100644 --- a/notifiers/providers/slack/blocks.py +++ b/notifiers/providers/slack/blocks.py @@ -26,7 +26,6 @@ class SlackBlockType(Enum): class SlackBaseBlock(SchemaModel): - type: SlackBlockType = Field(..., description="The type of block") block_id: constr(max_length=255) = Field( None, description="A string acting as a unique identifier for a block. " From 98ea2bc4809985b26f9e1b1a1915cf1556f18884 Mon Sep 17 00:00:00 2001 From: liiight Date: Tue, 3 Mar 2020 22:46:18 +0200 Subject: [PATCH 057/137] moved element group into blocks --- notifiers/providers/slack/blocks.py | 57 ++++++++++++++++++++++++--- notifiers/providers/slack/elements.py | 33 ---------------- 2 files changed, 51 insertions(+), 39 deletions(-) diff --git a/notifiers/providers/slack/blocks.py b/notifiers/providers/slack/blocks.py index ccaaa78c..1a4bed39 100644 --- a/notifiers/providers/slack/blocks.py +++ b/notifiers/providers/slack/blocks.py @@ -8,12 +8,57 @@ from pydantic import root_validator from typing_extensions import Literal +from .elements import SlackButtonElement +from .elements import SlackCheckboxElement +from .elements import SlackDatePickerElement +from .elements import SlackExternalSelectElement +from .elements import SlackImageElement +from .elements import SlackMultiSelectChannelsElement +from .elements import SlackMultiSelectConversationsElement +from .elements import SlackMultiSelectExternalMenuElement +from .elements import SlackMultiSelectUserListElement +from .elements import SlackMultiStaticSelectMenuElement +from .elements import SlackOverflowElement +from .elements import SlackRadioButtonGroupElement +from .elements import SlackSelectChannelsElement +from .elements import SlackSelectUsersElement +from .elements import SlackStaticSelectElement from notifiers.models.provider import SchemaModel from notifiers.providers.slack.composition import _text_object_factory +from notifiers.providers.slack.composition import SlackBlockTextObject from notifiers.providers.slack.composition import SlackTextType -from notifiers.providers.slack.elements import ActionsElements -from notifiers.providers.slack.elements import ContextElements -from notifiers.providers.slack.elements import SectionElements + +SectionBlockElements = Union[ + SlackButtonElement, + SlackCheckboxElement, + SlackDatePickerElement, + SlackImageElement, + SlackMultiStaticSelectMenuElement, + SlackMultiSelectExternalMenuElement, + SlackMultiSelectUserListElement, + SlackMultiSelectConversationsElement, + SlackMultiSelectChannelsElement, + SlackOverflowElement, + SlackRadioButtonGroupElement, + SlackStaticSelectElement, + SlackExternalSelectElement, + SlackSelectUsersElement, + SlackSelectChannelsElement, +] + +ActionsBlockElements = Union[ + SlackButtonElement, + SlackCheckboxElement, + SlackDatePickerElement, + SlackOverflowElement, + SlackRadioButtonGroupElement, + SlackStaticSelectElement, + SlackExternalSelectElement, + SlackSelectUsersElement, + SlackSelectChannelsElement, +] + +ContextBlockElements = Union[SlackImageElement, SlackBlockTextObject] class SlackBlockType(Enum): @@ -57,7 +102,7 @@ class SlackSectionBlock(SlackBaseBlock): max_items=10, alias="fields", ) - accessory: SectionElements = Field( + accessory: SectionBlockElements = Field( None, description="One of the available element objects" ) @@ -102,7 +147,7 @@ class SlackActionsBlock(SlackBaseBlock): ..., description="The type of block. For an actions block, type will always be actions", ) - elements: List[ActionsElements] = Field( + elements: List[ActionsBlockElements] = Field( ..., description="An array of interactive element objects - buttons, select menus, overflow menus, or date pickers", max_items=5, @@ -116,7 +161,7 @@ class SlackContextBlock(SlackBaseBlock): ..., description="The type of block. For a context block, type will always be context", ) - elements: List[ContextElements] = Field( + elements: List[ContextBlockElements] = Field( ..., description="An array of image elements and text objects", max_items=10 ) diff --git a/notifiers/providers/slack/elements.py b/notifiers/providers/slack/elements.py index 148559e4..d61c7f88 100644 --- a/notifiers/providers/slack/elements.py +++ b/notifiers/providers/slack/elements.py @@ -1,7 +1,6 @@ from datetime import date from enum import Enum from typing import List -from typing import Union from pydantic import constr from pydantic import Field @@ -12,7 +11,6 @@ from notifiers.models.provider import SchemaModel from notifiers.providers.slack.composition import _text_object_factory -from notifiers.providers.slack.composition import SlackBlockTextObject from notifiers.providers.slack.composition import SlackConfirmationDialog from notifiers.providers.slack.composition import SlackOption from notifiers.providers.slack.composition import SlackOptionGroup @@ -298,34 +296,3 @@ class SlackSelectUsersElement(SlackMultiSelectUserListElement): current user in the active workspace""" type = SlackElementType.users_select - - -SectionElements = Union[ - SlackButtonElement, - SlackCheckboxElement, - SlackDatePickerElement, - SlackImageElement, - SlackMultiStaticSelectMenuElement, - SlackMultiSelectExternalMenuElement, - SlackMultiSelectUserListElement, - SlackMultiSelectConversationsElement, - SlackMultiSelectChannelsElement, - SlackOverflowElement, - SlackRadioButtonGroupElement, - SlackStaticSelectElement, - SlackExternalSelectElement, - SlackSelectUsersElement, - SlackSelectChannelsElement, -] -ActionsElements = Union[ - SlackButtonElement, - SlackCheckboxElement, - SlackDatePickerElement, - SlackOverflowElement, - SlackRadioButtonGroupElement, - SlackStaticSelectElement, - SlackExternalSelectElement, - SlackSelectUsersElement, - SlackSelectChannelsElement, -] -ContextElements = Union[SlackImageElement, SlackBlockTextObject] From b79064b9899de5ca13fcde11d60f3db95d79cc3b Mon Sep 17 00:00:00 2001 From: liiight Date: Tue, 3 Mar 2020 23:11:24 +0200 Subject: [PATCH 058/137] added all import to slack main --- notifiers/providers/slack/__init__.py | 64 ++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/notifiers/providers/slack/__init__.py b/notifiers/providers/slack/__init__.py index 3cf1a532..1cc6f548 100644 --- a/notifiers/providers/slack/__init__.py +++ b/notifiers/providers/slack/__init__.py @@ -1 +1,63 @@ -from .main import Slack # noqa: F401 +from .blocks import SlackActionsBlock +from .blocks import SlackContextBlock +from .blocks import SlackDividerBlock +from .blocks import SlackFileBlock +from .blocks import SlackImageBlock +from .blocks import SlackSectionBlock +from .composition import SlackBlockTextObject +from .composition import SlackColor +from .composition import SlackConfirmationDialog +from .composition import SlackOption +from .composition import SlackOptionGroup +from .composition import SlackTextType +from .elements import SlackButtonElement +from .elements import SlackCheckboxElement +from .elements import SlackDatePickerElement +from .elements import SlackExternalSelectElement +from .elements import SlackImageElement +from .elements import SlackMultiSelectBaseElement +from .elements import SlackMultiSelectChannelsElement +from .elements import SlackMultiSelectConversationsElement +from .elements import SlackMultiSelectExternalMenuElement +from .elements import SlackMultiSelectUserListElement +from .elements import SlackMultiStaticSelectMenuElement +from .elements import SlackOverflowElement +from .elements import SlackRadioButtonGroupElement +from .elements import SlackSelectChannelsElement +from .elements import SlackSelectConversationsElement +from .elements import SlackSelectUsersElement +from .elements import SlackStaticSelectElement +from .main import Slack + +__all__ = [ + "Slack", + "SlackActionsBlock", + "SlackSectionBlock", + "SlackContextBlock", + "SlackDividerBlock", + "SlackFileBlock", + "SlackImageBlock", + "SlackButtonElement", + "SlackCheckboxElement", + "SlackDatePickerElement", + "SlackImageElement", + "SlackMultiSelectBaseElement", + "SlackMultiStaticSelectMenuElement", + "SlackMultiSelectExternalMenuElement", + "SlackMultiSelectUserListElement", + "SlackMultiSelectConversationsElement", + "SlackMultiSelectChannelsElement", + "SlackOverflowElement", + "SlackRadioButtonGroupElement", + "SlackStaticSelectElement", + "SlackExternalSelectElement", + "SlackSelectConversationsElement", + "SlackSelectChannelsElement", + "SlackSelectUsersElement", + "SlackBlockTextObject", + "SlackOption", + "SlackOptionGroup", + "SlackConfirmationDialog", + "SlackColor", + "SlackTextType", +] From dc409adef7b419a6ce5ced2bdbe9cefdeca875c7 Mon Sep 17 00:00:00 2001 From: liiight Date: Wed, 4 Mar 2020 09:22:08 +0200 Subject: [PATCH 059/137] readded defaults for ease of use when building via pydantic objects7 --- notifiers/providers/slack/__init__.py | 2 ++ notifiers/providers/slack/blocks.py | 13 +++++++------ notifiers/providers/slack/composition.py | 2 +- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/notifiers/providers/slack/__init__.py b/notifiers/providers/slack/__init__.py index 1cc6f548..e964dad5 100644 --- a/notifiers/providers/slack/__init__.py +++ b/notifiers/providers/slack/__init__.py @@ -28,9 +28,11 @@ from .elements import SlackSelectUsersElement from .elements import SlackStaticSelectElement from .main import Slack +from .main import SlackSchema __all__ = [ "Slack", + "SlackSchema", "SlackActionsBlock", "SlackSectionBlock", "SlackContextBlock", diff --git a/notifiers/providers/slack/blocks.py b/notifiers/providers/slack/blocks.py index 1a4bed39..14836a90 100644 --- a/notifiers/providers/slack/blocks.py +++ b/notifiers/providers/slack/blocks.py @@ -86,7 +86,7 @@ class SlackSectionBlock(SlackBaseBlock): in combination with text fields, or side-by-side with any of the available block elements""" type: Literal[SlackBlockType.section, SlackBlockType.section.value] = Field( - ..., + SlackBlockType.section, description="The type of block. For a section block, type will always be section", ) text: _text_object_factory("SectionBlockText", max_length=3000) = Field( @@ -118,7 +118,7 @@ class SlackDividerBlock(SlackBaseBlock): The divider block is nice and neat, requiring only a type.""" type: Literal[SlackBlockType.divider, SlackBlockType.divider.value] = Field( - ..., + SlackBlockType.divider, description="The type of block. For a divider block, type will always be divider", ) @@ -127,7 +127,7 @@ class SlackImageBlock(SlackBaseBlock): """A simple image block, designed to make those cat photos really pop""" type: Literal[SlackBlockType.image, SlackBlockType.image.value] = Field( - ..., + SlackBlockType.image, description="The type of block. For a image block, type will always be image", ) image_url: HttpUrl = Field(..., description="The URL of the image to be displayed") @@ -144,7 +144,7 @@ class SlackActionsBlock(SlackBaseBlock): """A block that is used to hold interactive elements""" type: Literal[SlackBlockType.actions, SlackBlockType.actions.value] = Field( - ..., + SlackBlockType.actions, description="The type of block. For an actions block, type will always be actions", ) elements: List[ActionsBlockElements] = Field( @@ -158,7 +158,7 @@ class SlackContextBlock(SlackBaseBlock): """Displays message context, which can include both images and text""" type: Literal[SlackBlockType.context, SlackBlockType.context.value] = Field( - ..., + SlackBlockType.context, description="The type of block. For a context block, type will always be context", ) elements: List[ContextBlockElements] = Field( @@ -170,7 +170,8 @@ class SlackFileBlock(SlackBaseBlock): """Displays a remote file""" type: Literal[SlackBlockType.file, SlackBlockType.file.value] = Field( - ..., description="The type of block. For a file block, type will always be file" + SlackBlockType.file, + description="The type of block. For a file block, type will always be file", ) external_id: str = Field(..., description="The external unique ID for this file") source: Literal["remote"] = Field( diff --git a/notifiers/providers/slack/composition.py b/notifiers/providers/slack/composition.py index 02dc657b..0bc53618 100644 --- a/notifiers/providers/slack/composition.py +++ b/notifiers/providers/slack/composition.py @@ -21,7 +21,7 @@ class SlackBlockTextObject(SchemaModel): """An object containing some text, formatted either as plain_text or using mrkdwn""" type: SlackTextType = Field( - ..., description="The formatting to use for this text object" + SlackTextType.markdown, description="The formatting to use for this text object" ) text: constr(max_length=3000) = Field( ..., From 382bd07ec03519ffb36cd3aa81074f753826ed3c Mon Sep 17 00:00:00 2001 From: liiight Date: Wed, 4 Mar 2020 09:35:03 +0200 Subject: [PATCH 060/137] renamed all the classes to get rid of the redundant Slack prefix --- notifiers/providers/slack/__init__.py | 116 ++++++++++---------- notifiers/providers/slack/blocks.py | 133 +++++++++++------------ notifiers/providers/slack/composition.py | 43 ++++---- notifiers/providers/slack/elements.py | 127 +++++++++++----------- notifiers/providers/slack/main.py | 14 +-- 5 files changed, 213 insertions(+), 220 deletions(-) diff --git a/notifiers/providers/slack/__init__.py b/notifiers/providers/slack/__init__.py index e964dad5..8997532b 100644 --- a/notifiers/providers/slack/__init__.py +++ b/notifiers/providers/slack/__init__.py @@ -1,65 +1,65 @@ -from .blocks import SlackActionsBlock -from .blocks import SlackContextBlock -from .blocks import SlackDividerBlock -from .blocks import SlackFileBlock -from .blocks import SlackImageBlock -from .blocks import SlackSectionBlock -from .composition import SlackBlockTextObject -from .composition import SlackColor -from .composition import SlackConfirmationDialog -from .composition import SlackOption -from .composition import SlackOptionGroup -from .composition import SlackTextType -from .elements import SlackButtonElement -from .elements import SlackCheckboxElement -from .elements import SlackDatePickerElement -from .elements import SlackExternalSelectElement -from .elements import SlackImageElement -from .elements import SlackMultiSelectBaseElement -from .elements import SlackMultiSelectChannelsElement -from .elements import SlackMultiSelectConversationsElement -from .elements import SlackMultiSelectExternalMenuElement -from .elements import SlackMultiSelectUserListElement -from .elements import SlackMultiStaticSelectMenuElement -from .elements import SlackOverflowElement -from .elements import SlackRadioButtonGroupElement -from .elements import SlackSelectChannelsElement -from .elements import SlackSelectConversationsElement -from .elements import SlackSelectUsersElement -from .elements import SlackStaticSelectElement +from .blocks import ActionsBlock +from .blocks import ContextBlock +from .blocks import DividerBlock +from .blocks import FileBlock +from .blocks import ImageBlock +from .blocks import SectionBlock +from .composition import BlockTextObject +from .composition import Color +from .composition import ConfirmationDialog +from .composition import Option +from .composition import OptionGroup +from .composition import TextType +from .elements import ButtonElement +from .elements import CheckboxElement +from .elements import DatePickerElement +from .elements import ExternalSelectElement +from .elements import ImageElement +from .elements import MultiSelectBaseElement +from .elements import MultiSelectChannelsElement +from .elements import MultiSelectConversationsElement +from .elements import MultiSelectExternalMenuElement +from .elements import MultiSelectUserListElement +from .elements import MultiStaticSelectMenuElement +from .elements import OverflowElement +from .elements import RadioButtonGroupElement +from .elements import SelectChannelsElement +from .elements import SelectConversationsElement +from .elements import SelectUsersElement +from .elements import StaticSelectElement from .main import Slack from .main import SlackSchema __all__ = [ "Slack", "SlackSchema", - "SlackActionsBlock", - "SlackSectionBlock", - "SlackContextBlock", - "SlackDividerBlock", - "SlackFileBlock", - "SlackImageBlock", - "SlackButtonElement", - "SlackCheckboxElement", - "SlackDatePickerElement", - "SlackImageElement", - "SlackMultiSelectBaseElement", - "SlackMultiStaticSelectMenuElement", - "SlackMultiSelectExternalMenuElement", - "SlackMultiSelectUserListElement", - "SlackMultiSelectConversationsElement", - "SlackMultiSelectChannelsElement", - "SlackOverflowElement", - "SlackRadioButtonGroupElement", - "SlackStaticSelectElement", - "SlackExternalSelectElement", - "SlackSelectConversationsElement", - "SlackSelectChannelsElement", - "SlackSelectUsersElement", - "SlackBlockTextObject", - "SlackOption", - "SlackOptionGroup", - "SlackConfirmationDialog", - "SlackColor", - "SlackTextType", + "ActionsBlock", + "SectionBlock", + "ContextBlock", + "DividerBlock", + "FileBlock", + "ImageBlock", + "ButtonElement", + "CheckboxElement", + "DatePickerElement", + "ImageElement", + "MultiSelectBaseElement", + "MultiStaticSelectMenuElement", + "MultiSelectExternalMenuElement", + "MultiSelectUserListElement", + "MultiSelectConversationsElement", + "MultiSelectChannelsElement", + "OverflowElement", + "RadioButtonGroupElement", + "StaticSelectElement", + "ExternalSelectElement", + "SelectConversationsElement", + "SelectChannelsElement", + "SelectUsersElement", + "BlockTextObject", + "Option", + "OptionGroup", + "ConfirmationDialog", + "Color", + "TextType", ] diff --git a/notifiers/providers/slack/blocks.py b/notifiers/providers/slack/blocks.py index 14836a90..d9a1deb3 100644 --- a/notifiers/providers/slack/blocks.py +++ b/notifiers/providers/slack/blocks.py @@ -8,60 +8,60 @@ from pydantic import root_validator from typing_extensions import Literal -from .elements import SlackButtonElement -from .elements import SlackCheckboxElement -from .elements import SlackDatePickerElement -from .elements import SlackExternalSelectElement -from .elements import SlackImageElement -from .elements import SlackMultiSelectChannelsElement -from .elements import SlackMultiSelectConversationsElement -from .elements import SlackMultiSelectExternalMenuElement -from .elements import SlackMultiSelectUserListElement -from .elements import SlackMultiStaticSelectMenuElement -from .elements import SlackOverflowElement -from .elements import SlackRadioButtonGroupElement -from .elements import SlackSelectChannelsElement -from .elements import SlackSelectUsersElement -from .elements import SlackStaticSelectElement +from .elements import ButtonElement +from .elements import CheckboxElement +from .elements import DatePickerElement +from .elements import ExternalSelectElement +from .elements import ImageElement +from .elements import MultiSelectChannelsElement +from .elements import MultiSelectConversationsElement +from .elements import MultiSelectExternalMenuElement +from .elements import MultiSelectUserListElement +from .elements import MultiStaticSelectMenuElement +from .elements import OverflowElement +from .elements import RadioButtonGroupElement +from .elements import SelectChannelsElement +from .elements import SelectUsersElement +from .elements import StaticSelectElement from notifiers.models.provider import SchemaModel from notifiers.providers.slack.composition import _text_object_factory -from notifiers.providers.slack.composition import SlackBlockTextObject -from notifiers.providers.slack.composition import SlackTextType +from notifiers.providers.slack.composition import BlockTextObject +from notifiers.providers.slack.composition import TextType SectionBlockElements = Union[ - SlackButtonElement, - SlackCheckboxElement, - SlackDatePickerElement, - SlackImageElement, - SlackMultiStaticSelectMenuElement, - SlackMultiSelectExternalMenuElement, - SlackMultiSelectUserListElement, - SlackMultiSelectConversationsElement, - SlackMultiSelectChannelsElement, - SlackOverflowElement, - SlackRadioButtonGroupElement, - SlackStaticSelectElement, - SlackExternalSelectElement, - SlackSelectUsersElement, - SlackSelectChannelsElement, + ButtonElement, + CheckboxElement, + DatePickerElement, + ImageElement, + MultiStaticSelectMenuElement, + MultiSelectExternalMenuElement, + MultiSelectUserListElement, + MultiSelectConversationsElement, + MultiSelectChannelsElement, + OverflowElement, + RadioButtonGroupElement, + StaticSelectElement, + ExternalSelectElement, + SelectUsersElement, + SelectChannelsElement, ] ActionsBlockElements = Union[ - SlackButtonElement, - SlackCheckboxElement, - SlackDatePickerElement, - SlackOverflowElement, - SlackRadioButtonGroupElement, - SlackStaticSelectElement, - SlackExternalSelectElement, - SlackSelectUsersElement, - SlackSelectChannelsElement, + ButtonElement, + CheckboxElement, + DatePickerElement, + OverflowElement, + RadioButtonGroupElement, + StaticSelectElement, + ExternalSelectElement, + SelectUsersElement, + SelectChannelsElement, ] -ContextBlockElements = Union[SlackImageElement, SlackBlockTextObject] +ContextBlockElements = Union[ImageElement, BlockTextObject] -class SlackBlockType(Enum): +class BlockType(Enum): section = "section" divider = "divider" image = "image" @@ -70,7 +70,7 @@ class SlackBlockType(Enum): file = "file" -class SlackBaseBlock(SchemaModel): +class BaseBlock(SchemaModel): block_id: constr(max_length=255) = Field( None, description="A string acting as a unique identifier for a block. " @@ -81,12 +81,12 @@ class SlackBaseBlock(SchemaModel): ) -class SlackSectionBlock(SlackBaseBlock): +class SectionBlock(BaseBlock): """A section is one of the most flexible blocks available - it can be used as a simple text block, in combination with text fields, or side-by-side with any of the available block elements""" - type: Literal[SlackBlockType.section, SlackBlockType.section.value] = Field( - SlackBlockType.section, + type: Literal[BlockType.section, BlockType.section.value] = Field( + BlockType.section, description="The type of block. For a section block, type will always be section", ) text: _text_object_factory("SectionBlockText", max_length=3000) = Field( @@ -113,21 +113,21 @@ def text_or_field(cls, values): return values -class SlackDividerBlock(SlackBaseBlock): +class DividerBlock(BaseBlock): """A content divider, like an
, to split up different blocks inside of a message. The divider block is nice and neat, requiring only a type.""" - type: Literal[SlackBlockType.divider, SlackBlockType.divider.value] = Field( - SlackBlockType.divider, + type: Literal[BlockType.divider, BlockType.divider.value] = Field( + BlockType.divider, description="The type of block. For a divider block, type will always be divider", ) -class SlackImageBlock(SlackBaseBlock): +class ImageBlock(BaseBlock): """A simple image block, designed to make those cat photos really pop""" - type: Literal[SlackBlockType.image, SlackBlockType.image.value] = Field( - SlackBlockType.image, + type: Literal[BlockType.image, BlockType.image.value] = Field( + BlockType.image, description="The type of block. For a image block, type will always be image", ) image_url: HttpUrl = Field(..., description="The URL of the image to be displayed") @@ -136,15 +136,15 @@ class SlackImageBlock(SlackBaseBlock): description="A plain-text summary of the image. This should not contain any markup", ) title: _text_object_factory( - "ImageText", max_length=2000, type=SlackTextType.plain_text + "ImageText", max_length=2000, type=TextType.plain_text ) = Field(None, description="An optional title for the image") -class SlackActionsBlock(SlackBaseBlock): +class ActionsBlock(BaseBlock): """A block that is used to hold interactive elements""" - type: Literal[SlackBlockType.actions, SlackBlockType.actions.value] = Field( - SlackBlockType.actions, + type: Literal[BlockType.actions, BlockType.actions.value] = Field( + BlockType.actions, description="The type of block. For an actions block, type will always be actions", ) elements: List[ActionsBlockElements] = Field( @@ -154,11 +154,11 @@ class SlackActionsBlock(SlackBaseBlock): ) -class SlackContextBlock(SlackBaseBlock): +class ContextBlock(BaseBlock): """Displays message context, which can include both images and text""" - type: Literal[SlackBlockType.context, SlackBlockType.context.value] = Field( - SlackBlockType.context, + type: Literal[BlockType.context, BlockType.context.value] = Field( + BlockType.context, description="The type of block. For a context block, type will always be context", ) elements: List[ContextBlockElements] = Field( @@ -166,11 +166,11 @@ class SlackContextBlock(SlackBaseBlock): ) -class SlackFileBlock(SlackBaseBlock): +class FileBlock(BaseBlock): """Displays a remote file""" - type: Literal[SlackBlockType.file, SlackBlockType.file.value] = Field( - SlackBlockType.file, + type: Literal[BlockType.file, BlockType.file.value] = Field( + BlockType.file, description="The type of block. For a file block, type will always be file", ) external_id: str = Field(..., description="The external unique ID for this file") @@ -181,10 +181,5 @@ class SlackFileBlock(SlackBaseBlock): Blocks = Union[ - SlackSectionBlock, - SlackDividerBlock, - SlackImageBlock, - SlackActionsBlock, - SlackContextBlock, - SlackFileBlock, + SectionBlock, DividerBlock, ImageBlock, ActionsBlock, ContextBlock, FileBlock, ] diff --git a/notifiers/providers/slack/composition.py b/notifiers/providers/slack/composition.py index 0bc53618..3714728f 100644 --- a/notifiers/providers/slack/composition.py +++ b/notifiers/providers/slack/composition.py @@ -12,16 +12,16 @@ from notifiers.models.provider import SchemaModel -class SlackTextType(Enum): +class TextType(Enum): plain_text = "plain_text" markdown = "mrkdwn" -class SlackBlockTextObject(SchemaModel): +class BlockTextObject(SchemaModel): """An object containing some text, formatted either as plain_text or using mrkdwn""" - type: SlackTextType = Field( - SlackTextType.markdown, description="The formatting to use for this text object" + type: TextType = Field( + TextType.markdown, description="The formatting to use for this text object" ) text: constr(max_length=3000) = Field( ..., @@ -43,37 +43,34 @@ class SlackBlockTextObject(SchemaModel): @root_validator def check_emoji(cls, values): - if ( - values.get("emoji") - and SlackTextType(values["type"]) is not SlackTextType.plain_text - ): + if values.get("emoji") and TextType(values["type"]) is not TextType.plain_text: raise ValueError("Cannot use 'emoji' when type is not 'plain_text'") return values class Config: - json_encoders = {SlackTextType: lambda v: v.value} + json_encoders = {TextType: lambda v: v.value} def _text_object_factory( - model_name: str, max_length: int, type: SlackTextType = None -) -> Type[SlackBlockTextObject]: + model_name: str, max_length: int, type: TextType = None +) -> Type[BlockTextObject]: """Returns a custom text object schema. If a `type_` is passed, it's enforced as the only possible value (both the enum and its value) and set as the default""" - type_value = (Literal[type, type.value], type) if type else (SlackTextType, ...) + type_value = (Literal[type, type.value], type) if type else (TextType, ...) return create_model( model_name, type=type_value, text=(constr(max_length=max_length), ...), - __base__=SlackBlockTextObject, + __base__=BlockTextObject, ) -class SlackOption(SchemaModel): +class Option(SchemaModel): """An object that represents a single selectable item in a select menu, multi-select menu, radio button group, or overflow menu.""" text: _text_object_factory( - "OptionText", max_length=75, type=SlackTextType.plain_text + "OptionText", max_length=75, type=TextType.plain_text ) = Field( ..., description="A plain_text only text object that defines the text shown in the option on the menu." @@ -84,7 +81,7 @@ class SlackOption(SchemaModel): description="The string value that will be passed to your app when this option is chosen", ) description: _text_object_factory( - "DescriptionText", max_length=75, type=SlackTextType.plain_text + "DescriptionText", max_length=75, type=TextType.plain_text ) = Field( None, description="A plain_text only text object that defines a line of descriptive text shown below the " @@ -99,37 +96,37 @@ class SlackOption(SchemaModel): ) -class SlackOptionGroup(SchemaModel): +class OptionGroup(SchemaModel): """Provides a way to group options in a select menu or multi-select menu""" label: _text_object_factory( - "OptionGroupText", max_length=75, type=SlackTextType.plain_text + "OptionGroupText", max_length=75, type=TextType.plain_text ) = Field( ..., description="A plain_text only text object that defines the label shown above this group of options", ) - options: List[SlackOption] = Field( + options: List[Option] = Field( ..., description="An array of option objects that belong to this specific group. Maximum of 100 items", max_items=100, ) -class SlackConfirmationDialog(SchemaModel): +class ConfirmationDialog(SchemaModel): """An object that defines a dialog that provides a confirmation step to any interactive element. This dialog will ask the user to confirm their action by offering a confirm and deny buttons.""" title: _text_object_factory( - "DialogTitleText", max_length=100, type=SlackTextType.plain_text + "DialogTitleText", max_length=100, type=TextType.plain_text ) text: _text_object_factory("DialogTextText", max_length=300) confirm: _text_object_factory("DialogConfirmText", max_length=30) deny: _text_object_factory( - "DialogDenyText", max_length=30, type=SlackTextType.plain_text + "DialogDenyText", max_length=30, type=TextType.plain_text ) -class SlackColor(Enum): +class Color(Enum): good = "good" warning = "warning" danger = "danger" diff --git a/notifiers/providers/slack/elements.py b/notifiers/providers/slack/elements.py index d61c7f88..bc0964b7 100644 --- a/notifiers/providers/slack/elements.py +++ b/notifiers/providers/slack/elements.py @@ -7,17 +7,16 @@ from pydantic import HttpUrl from pydantic import PositiveInt from pydantic import root_validator -from pydantic import validator from notifiers.models.provider import SchemaModel from notifiers.providers.slack.composition import _text_object_factory -from notifiers.providers.slack.composition import SlackConfirmationDialog -from notifiers.providers.slack.composition import SlackOption -from notifiers.providers.slack.composition import SlackOptionGroup -from notifiers.providers.slack.composition import SlackTextType +from notifiers.providers.slack.composition import ConfirmationDialog +from notifiers.providers.slack.composition import Option +from notifiers.providers.slack.composition import OptionGroup +from notifiers.providers.slack.composition import TextType -class SlackElementType(Enum): +class ElementType(Enum): button = "button" checkboxes = "checkboxes" date_picker = "datepicker" @@ -39,8 +38,8 @@ class SlackElementType(Enum): channels_select = "channels_select" -class SlackBaseElement(SchemaModel): - type: SlackElementType = Field(..., description="The type of element") +class _BaseElement(SchemaModel): + type: ElementType = Field(..., description="The type of element") action_id: constr(max_length=255) = Field( None, description="An identifier for this action. You can use this when you receive an interaction payload to " @@ -49,20 +48,20 @@ class SlackBaseElement(SchemaModel): ) class Config: - json_encoders = {SlackElementType: lambda v: v.value} + json_encoders = {ElementType: lambda v: v.value} -class SlackButtonElementStyle(Enum): +class ButtonElementStyle(Enum): primary = "primary" danger = "danger" default = None -class SlackButtonElement(SlackBaseElement): +class ButtonElement(_BaseElement): """An interactive component that inserts a button. The button can be a trigger for anything from opening a simple link to starting a complex workflow.""" - type = SlackElementType.button + type = ElementType.button text: _text_object_factory("ElementText", max_length=75) = Field( ..., description="A text object that defines the button's text" ) @@ -77,38 +76,44 @@ class SlackButtonElement(SlackBaseElement): description="The value to send along with the interaction payload. " "Maximum length for this field is 2000 characters", ) - style: SlackButtonElementStyle = Field( + style: ButtonElementStyle = Field( None, description="Decorates buttons with alternative visual color schemes. Use this option with restraint", ) - confirm: SlackConfirmationDialog = Field( + confirm: ConfirmationDialog = Field( None, description="A confirm object that defines an optional confirmation dialog after the button is clicked.", ) + class Config: + json_encoders = { + ElementType: lambda v: v.value, + ButtonElementStyle: lambda v: v.value, + } + -class SlackCheckboxElement(SlackBaseElement): +class CheckboxElement(_BaseElement): """A checkbox group that allows a user to choose multiple items from a list of possible options""" - type = SlackElementType.checkboxes - options: List[SlackOption] = Field(..., description="An array of option objects") - initial_options: List[SlackOption] = Field( + type = ElementType.checkboxes + options: List[Option] = Field(..., description="An array of option objects") + initial_options: List[Option] = Field( ..., description="An array of option objects that exactly matches one or more of the options within options." " These options will be selected when the checkbox group initially loads", ) - confirm: SlackConfirmationDialog = Field( + confirm: ConfirmationDialog = Field( None, description="A confirm object that defines an optional confirmation dialog that appears after " "clicking one of the checkboxes in this element.", ) -class SlackDatePickerElement(SlackBaseElement): +class DatePickerElement(_BaseElement): """An element which lets users easily select a date from a calendar style UI.""" placeholder: _text_object_factory( - "DatePicketText", max_length=150, type=SlackTextType.plain_text + "DatePicketText", max_length=150, type=TextType.plain_text ) = Field( None, description="A plain_text only text object that defines the placeholder text shown on the datepicker." @@ -117,21 +122,17 @@ class SlackDatePickerElement(SlackBaseElement): initial_date: date = Field( None, description="The initial date that is selected when the element is loaded" ) - confirm: SlackConfirmationDialog = Field( + confirm: ConfirmationDialog = Field( None, description="A confirm object that defines an optional confirmation dialog that appears" " after a date is selected.", ) - @validator("initial_date") - def format_date(cls, v: date): - return str(v) - -class SlackImageElement(SlackBaseElement): +class ImageElement(_BaseElement): """A plain-text summary of the image. This should not contain any markup""" - type = SlackElementType.image + type = ElementType.image image_url: HttpUrl = Field(..., description="The URL of the image to be displayed") alt_text: str = Field( ..., @@ -139,19 +140,19 @@ class SlackImageElement(SlackBaseElement): ) -class SlackMultiSelectBaseElement(SlackBaseElement): +class MultiSelectBaseElement(_BaseElement): placeholder: _text_object_factory( - "MultiSelectText", max_length=150, type=SlackTextType.plain_text + "MultiSelectText", max_length=150, type=TextType.plain_text ) = Field( ..., description="A plain_text only text object that defines the placeholder text shown on the menu", ) - initial_options: List[SlackOption] = Field( + initial_options: List[Option] = Field( None, description="An array of option objects that exactly match one or more of the options within options " "or option_groups. These options will be selected when the menu initially loads.", ) - confirm: SlackConfirmationDialog = Field( + confirm: ConfirmationDialog = Field( None, description="A confirm object that defines an optional confirmation dialog that appears before " "the multi-select choices are submitted", @@ -162,15 +163,15 @@ class SlackMultiSelectBaseElement(SlackBaseElement): ) -class SlackMultiStaticSelectMenuElement(SlackMultiSelectBaseElement): +class MultiStaticSelectMenuElement(MultiSelectBaseElement): """This is the simplest form of select menu, with a static list of options passed in when defining the element.""" - type = SlackElementType.multi_static_select + type = ElementType.multi_static_select - options: List[SlackOption] = Field( + options: List[Option] = Field( None, description="An array of option objects.", max_items=100 ) - option_groups: List[SlackOptionGroup] = Field( + option_groups: List[OptionGroup] = Field( None, description="An array of option group objects", max_items=100 ) @@ -184,10 +185,10 @@ def option_check(cls, values): return values -class SlackMultiSelectExternalMenuElement(SlackMultiSelectBaseElement): +class MultiSelectExternalMenuElement(MultiSelectBaseElement): """This menu will load its options from an external data source, allowing for a dynamic list of options.""" - type = SlackElementType.multi_external_select + type = ElementType.multi_external_select min_query_length: PositiveInt = Field( None, description="When the typeahead field is used, a request will be sent on every character change. " @@ -196,103 +197,103 @@ class SlackMultiSelectExternalMenuElement(SlackMultiSelectBaseElement): ) -class SlackMultiSelectUserListElement(SlackMultiSelectBaseElement): +class MultiSelectUserListElement(MultiSelectBaseElement): """This multi-select menu will populate its options with a list of Slack users visible to the current user in the active workspace.""" - type = SlackElementType.multi_users_select + type = ElementType.multi_users_select initial_users: List[str] = Field( None, description="An array of user IDs of any valid users to be pre-selected when the menu loads.", ) -class SlackMultiSelectConversationsElement(SlackMultiSelectBaseElement): +class MultiSelectConversationsElement(MultiSelectBaseElement): """This multi-select menu will populate its options with a list of public and private channels, DMs, and MPIMs visible to the current user in the active workspace""" - type = SlackElementType.multi_conversations_select + type = ElementType.multi_conversations_select initial_conversations: List[str] = Field( None, description="An array of one or more IDs of any valid conversations to be pre-selected when the menu loads", ) -class SlackMultiSelectChannelsElement(SlackMultiSelectBaseElement): +class MultiSelectChannelsElement(MultiSelectBaseElement): """This multi-select menu will populate its options with a list of public channels visible to the current user in the active workspace""" - type = SlackElementType.multi_channels_select + type = ElementType.multi_channels_select initial_channels: List[str] = Field( None, description="An array of one or more IDs of any valid public channel to be pre-selected when the menu loads", ) -class SlackOverflowElement(SlackBaseElement): +class OverflowElement(_BaseElement): """This is like a cross between a button and a select menu - when a user clicks on this overflow button, they will be presented with a list of options to choose from. Unlike the select menu, there is no typeahead field, and the button always appears with an ellipsis ("…") rather than customisable text.""" - type = SlackElementType.overflow - options: List[SlackOption] = Field( + type = ElementType.overflow + options: List[Option] = Field( ..., description="An array of option objects to display in the menu", min_items=2, max_items=5, ) - confirm: SlackConfirmationDialog = Field( + confirm: ConfirmationDialog = Field( None, description="A confirm object that defines an optional confirmation dialog that appears after a menu " "item is selected", ) -class SlackRadioButtonGroupElement(SlackBaseElement): +class RadioButtonGroupElement(_BaseElement): """A radio button group that allows a user to choose one item from a list of possible options""" - type = SlackElementType.radio_buttons - options: List[SlackOption] = Field(..., description="An array of option objects") - initial_option: SlackOption = Field( + type = ElementType.radio_buttons + options: List[Option] = Field(..., description="An array of option objects") + initial_option: Option = Field( None, description="An option object that exactly matches one of the options within options." " This option will be selected when the radio button group initially loads.", ) - confirm: SlackConfirmationDialog = Field( + confirm: ConfirmationDialog = Field( None, description="A confirm object that defines an optional confirmation dialog that appears after " "clicking one of the radio buttons in this element", ) -class SlackStaticSelectElement(SlackMultiStaticSelectMenuElement): +class StaticSelectElement(MultiStaticSelectMenuElement): """This is the simplest form of select menu, with a static list of options passed in when defining the element""" - type = SlackElementType.static_select + type = ElementType.static_select -class SlackExternalSelectElement(SlackMultiSelectExternalMenuElement): +class ExternalSelectElement(MultiSelectExternalMenuElement): """This select menu will load its options from an external data source, allowing for a dynamic list of options""" - type = SlackElementType.external_select + type = ElementType.external_select -class SlackSelectConversationsElement(SlackMultiSelectConversationsElement): +class SelectConversationsElement(MultiSelectConversationsElement): """This select menu will populate its options with a list of public and private channels, DMs, and MPIMs visible to the current user in the active workspace.""" - type = SlackElementType.conversations_select + type = ElementType.conversations_select -class SlackSelectChannelsElement(SlackMultiSelectChannelsElement): +class SelectChannelsElement(MultiSelectChannelsElement): """This select menu will populate its options with a list of public channels visible to the current user in the active workspace.""" - type = SlackElementType.channels_select + type = ElementType.channels_select -class SlackSelectUsersElement(SlackMultiSelectUserListElement): +class SelectUsersElement(MultiSelectUserListElement): """This select menu will populate its options with a list of Slack users visible to the current user in the active workspace""" - type = SlackElementType.users_select + type = ElementType.users_select diff --git a/notifiers/providers/slack/main.py b/notifiers/providers/slack/main.py index d344d096..72b636f3 100644 --- a/notifiers/providers/slack/main.py +++ b/notifiers/providers/slack/main.py @@ -13,11 +13,11 @@ from notifiers.models.provider import SchemaModel from notifiers.models.response import Response from notifiers.providers.slack.blocks import Blocks -from notifiers.providers.slack.composition import SlackColor +from notifiers.providers.slack.composition import Color from notifiers.utils import requests -class SlackFieldObject(SchemaModel): +class FieldObject(SchemaModel): title: str = Field( None, description="Shown as a bold heading displayed in the field object." @@ -35,7 +35,7 @@ class SlackFieldObject(SchemaModel): ) -class SlackAttachmentSchema(SchemaModel): +class AttachmentSchema(SchemaModel): """Secondary content can be attached to messages to include lower priority content - content that doesn't necessarily need to be seen to appreciate the intent of the message, but perhaps adds further context or additional information.""" @@ -45,7 +45,7 @@ class SlackAttachmentSchema(SchemaModel): description="An array of layout blocks in the same format as described in the building blocks guide.", max_items=50, ) - color: Union[SlackColor, ColorType] = Field( + color: Union[Color, ColorType] = Field( None, description="Changes the color of the border on the left side of this attachment from the default gray", ) @@ -66,7 +66,7 @@ class SlackAttachmentSchema(SchemaModel): description="A plain text summary of the attachment used in clients that don't show " "formatted text (eg. IRC, mobile notifications)", ) - attachment_fields: List[SlackFieldObject] = Field( + attachment_fields: List[FieldObject] = Field( None, description="An array of field objects that get displayed in a table-like way." " For best results, include no more than 2-3 field objects", @@ -134,7 +134,7 @@ class SlackAttachmentSchema(SchemaModel): ) @validator("color") - def color_format(cls, v: Union[SlackColor, ColorType]): + def color_format(cls, v: Union[Color, ColorType]): return v.as_hex() if isinstance(v, ColorType) else v.value @validator("timestamp") @@ -172,7 +172,7 @@ class SlackSchema(SchemaModel): description="An array of layout blocks in the same format as described in the building blocks guide.", max_items=50, ) - attachments: List[SlackAttachmentSchema] = Field( + attachments: List[AttachmentSchema] = Field( None, description="An array of legacy secondary attachments. We recommend you use blocks instead.", ) From f3f8671a7b495706f8081b060c14175bd1c34308 Mon Sep 17 00:00:00 2001 From: liiight Date: Wed, 4 Mar 2020 09:38:54 +0200 Subject: [PATCH 061/137] renamed BlockTextObject to Text --- notifiers/providers/slack/__init__.py | 4 ++-- notifiers/providers/slack/blocks.py | 4 ++-- notifiers/providers/slack/composition.py | 9 +++++---- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/notifiers/providers/slack/__init__.py b/notifiers/providers/slack/__init__.py index 8997532b..8597637c 100644 --- a/notifiers/providers/slack/__init__.py +++ b/notifiers/providers/slack/__init__.py @@ -4,11 +4,11 @@ from .blocks import FileBlock from .blocks import ImageBlock from .blocks import SectionBlock -from .composition import BlockTextObject from .composition import Color from .composition import ConfirmationDialog from .composition import Option from .composition import OptionGroup +from .composition import Text from .composition import TextType from .elements import ButtonElement from .elements import CheckboxElement @@ -56,7 +56,7 @@ "SelectConversationsElement", "SelectChannelsElement", "SelectUsersElement", - "BlockTextObject", + "Text", "Option", "OptionGroup", "ConfirmationDialog", diff --git a/notifiers/providers/slack/blocks.py b/notifiers/providers/slack/blocks.py index d9a1deb3..fc106a82 100644 --- a/notifiers/providers/slack/blocks.py +++ b/notifiers/providers/slack/blocks.py @@ -25,7 +25,7 @@ from .elements import StaticSelectElement from notifiers.models.provider import SchemaModel from notifiers.providers.slack.composition import _text_object_factory -from notifiers.providers.slack.composition import BlockTextObject +from notifiers.providers.slack.composition import Text from notifiers.providers.slack.composition import TextType SectionBlockElements = Union[ @@ -58,7 +58,7 @@ SelectChannelsElement, ] -ContextBlockElements = Union[ImageElement, BlockTextObject] +ContextBlockElements = Union[ImageElement, Text] class BlockType(Enum): diff --git a/notifiers/providers/slack/composition.py b/notifiers/providers/slack/composition.py index 3714728f..5c237415 100644 --- a/notifiers/providers/slack/composition.py +++ b/notifiers/providers/slack/composition.py @@ -17,8 +17,9 @@ class TextType(Enum): markdown = "mrkdwn" -class BlockTextObject(SchemaModel): - """An object containing some text, formatted either as plain_text or using mrkdwn""" +class Text(SchemaModel): + """An object containing some text, formatted either as plain_text or using mrkdwn, + our proprietary textual markup that's just different enough from Markdown to frustrate you""" type: TextType = Field( TextType.markdown, description="The formatting to use for this text object" @@ -53,7 +54,7 @@ class Config: def _text_object_factory( model_name: str, max_length: int, type: TextType = None -) -> Type[BlockTextObject]: +) -> Type[Text]: """Returns a custom text object schema. If a `type_` is passed, it's enforced as the only possible value (both the enum and its value) and set as the default""" type_value = (Literal[type, type.value], type) if type else (TextType, ...) @@ -61,7 +62,7 @@ def _text_object_factory( model_name, type=type_value, text=(constr(max_length=max_length), ...), - __base__=BlockTextObject, + __base__=Text, ) From 2deae455464c125f5d0ab76e20dbd8bdf9628804 Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 5 Mar 2020 01:24:05 +0200 Subject: [PATCH 062/137] added statuspage schema --- notifiers/providers/statuspage.py | 324 ++++++++++++++++++------------ 1 file changed, 191 insertions(+), 133 deletions(-) diff --git a/notifiers/providers/statuspage.py b/notifiers/providers/statuspage.py index 545d99ee..5b94f745 100644 --- a/notifiers/providers/statuspage.py +++ b/notifiers/providers/statuspage.py @@ -1,11 +1,182 @@ -from ..exceptions import BadArguments +from datetime import datetime +from enum import Enum +from typing import Dict +from typing import List +from urllib.parse import urljoin + +from pydantic import Field +from pydantic import root_validator +from pydantic.json import isoformat + from ..exceptions import ResourceError from ..models.provider import Provider from ..models.provider import ProviderResource +from ..models.provider import SchemaModel from ..models.response import Response from ..utils import requests +class Impact(Enum): + critical = "critical" + major = "major" + minor = "minor" + maintenance = "maintenance" + none = "none" + + +class IncidentStatus(Enum): + postmortem = "postmortem" + investigating = "investigating" + identified = "identified" + resolved = "resolved" + update = "update" + scheduled = "scheduled" + in_progress = "in_progress" + verifying = "verifying" + monitoring = "monitoring" + completed = "completed" + + +class ComponentStatus(Enum): + operational = "operational" + under_maintenance = "under_maintenance" + degraded_performance = "degraded_performance" + partial_outage = "partial_outage" + major_outage = "major_outage" + empty = "" + + +class StatuspageBaseSchema(SchemaModel): + api_key: str = Field(..., description="Authentication token") + page_id: str = Field(..., description="Paged ID") + + +class StatuspageSchema(StatuspageBaseSchema): + """Statuspage incident creation schema""" + + message: str = Field(..., description="Incident Name", alias="name") + status: IncidentStatus = Field(None, description="Incident status") + impact_override: Impact = Field( + None, description="Value to override calculated impact value" + ) + scheduled_for: datetime = Field( + None, description="The timestamp the incident is scheduled for" + ) + scheduled_until: datetime = Field( + None, description="The timestamp the incident is scheduled until" + ) + scheduled_remind_prior: bool = Field( + None, + description="Controls whether to remind subscribers prior to scheduled incidents", + ) + scheduled_auto_in_progress: bool = Field( + None, + description="Controls whether the incident is scheduled to automatically change to in progress", + ) + scheduled_auto_completed: bool = Field( + None, + description="Controls whether the incident is scheduled to automatically change to complete", + ) + metadata: dict = Field( + None, + description="Attach a json object to the incident. All top-level values in the object must also be objects", + ) + deliver_notifications: bool = Field( + None, + description="Deliver notifications to subscribers if this is true. If this is false, " + "create an incident without notifying customers", + ) + auto_transition_deliver_notifications_at_end: bool = Field( + None, + description="Controls whether send notification when scheduled maintenances auto transition to completed", + ) + auto_transition_deliver_notifications_at_start: bool = Field( + None, + description="Controls whether send notification when scheduled maintenances auto transition to started", + ) + auto_transition_to_maintenance_state: bool = Field( + None, + description="Controls whether send notification when scheduled maintenances auto transition to in progress", + ) + auto_transition_to_operational_state: bool = Field( + None, + description="Controls whether change components status to operational once scheduled maintenance completes", + ) + auto_tweet_at_beginning: bool = Field( + None, + description="Controls whether tweet automatically when scheduled maintenance starts", + ) + auto_tweet_on_completion: bool = Field( + None, + description="Controls whether tweet automatically when scheduled maintenance completes", + ) + auto_tweet_on_creation: bool = Field( + None, + description="Controls whether tweet automatically when scheduled maintenance is created", + ) + auto_tweet_one_hour_before: bool = Field( + None, + description="Controls whether tweet automatically one hour before scheduled maintenance starts", + ) + backfill_date: datetime = Field( + None, description="TimeStamp when incident was backfilled" + ) + backfilled: bool = Field( + None, + description="Controls whether incident is backfilled. If true, components cannot be specified", + ) + body: str = Field( + None, description="The initial message, created as the first incident update" + ) + components: Dict[str, ComponentStatus] = Field( + None, description="Map of status changes to apply to affected components" + ) + component_ids: List[str] = Field( + None, description="List of component_ids affected by this incident" + ) + scheduled_auto_transition: bool = Field( + None, + description="Same as 'scheduled_auto_transition_in_progress'. Controls whether the incident is " + "scheduled to automatically change to in progress", + ) + + @root_validator + def values_dependencies(cls, values): + backfill_values = [values.get(v) for v in ("backfill_date", "backfilled")] + scheduled_values = [values.get(v) for v in ("scheduled_for", "scheduled_until")] + + if any(backfill_values) and not all(backfill_values): + raise ValueError( + "Cannot set just one of 'backfill_date' and 'backfilled', both need to be set" + ) + if any(scheduled_values) and not all(scheduled_values): + raise ValueError( + "Cannot set just one of 'scheduled_for' and 'scheduled_until', both need to be set" + ) + if any( + values.get(v) + for v in ( + "scheduled_until", + "scheduled_remind_prior", + "scheduled_auto_in_progress", + "scheduled_auto_completed", + ) + ) and not values.get("scheduled_for"): + raise ValueError( + "'scheduled_for' must be set when setting scheduled attributes" + ) + if any(backfill_values) and any(scheduled_values): + raise ValueError( + "Cannot set both backfill attributes and scheduled attributes" + ) + if any(backfill_values) and values.get("status"): + raise ValueError("Cannot set 'status' when setting 'backfill'") + return values + + class Config: + json_encoders = {datetime: isoformat} + + class StatuspageMixin: """Shared resources between :class:`Statuspage` and :class:`StatuspageComponents`""" @@ -14,12 +185,16 @@ class StatuspageMixin: path_to_errors = ("error",) site_url = "https://statuspage.io" + @staticmethod + def request_headers(api_key: str) -> dict: + return {"Authorization": f"OAuth {api_key}"} + class StatuspageComponents(StatuspageMixin, ProviderResource): """Return a list of Statuspage components for the page ID""" resource_name = "components" - components_url = "components.json" + components_url = "components" _required = {"required": ["api_key", "page_id"]} @@ -33,10 +208,12 @@ class StatuspageComponents(StatuspageMixin, ProviderResource): } def _get_resource(self, data: dict) -> dict: - url = self.base_url.format(page_id=data["page_id"]) + self.components_url - params = {"api_key": data.pop("api_key")} + url = urljoin( + self.base_url.format(page_id=data["page_id"]), self.components_url + ) + headers = self.request_headers(data.pop("api_key")) response, errors = requests.get( - url, params=params, path_to_errors=self.path_to_errors + url, headers=headers, path_to_errors=self.path_to_errors ) if errors: raise ResourceError( @@ -52,138 +229,19 @@ def _get_resource(self, data: dict) -> dict: class Statuspage(StatuspageMixin, Provider): """Create Statuspage incidents""" - incidents_url = "incidents.json" + incidents_url = "incidents" _resources = {"components": StatuspageComponents()} - realtime_statuses = ["investigating", "identified", "monitoring", "resolved"] - - scheduled_statuses = ["scheduled", "in_progress", "verifying", "completed"] - - __component_ids = { - "type": "array", - "items": {"type": "string"}, - "title": "List of components whose subscribers should be notified (only applicable for pages with " - "component subscriptions enabled)", - } + schema_model = StatuspageSchema - _required = {"required": ["message", "api_key", "page_id"]} - - _schema = { - "type": "object", - "properties": { - "message": {"type": "string", "title": "The name of the incident"}, - "api_key": {"type": "string", "title": "OAuth2 token"}, - "page_id": {"type": "string", "title": "Page ID"}, - "status": { - "type": "string", - "title": "Status of the incident", - "enum": realtime_statuses + scheduled_statuses, - }, - "body": { - "type": "string", - "title": "The initial message, created as the first incident update", - }, - "wants_twitter_update": { - "type": "boolean", - "title": "Post the new incident to twitter", - }, - "impact_override": { - "type": "string", - "title": "Override calculated impact value", - "enum": ["none", "minor", "major", "critical"], - }, - "component_ids": __component_ids, - "deliver_notifications": { - "type": "boolean", - "title": "Control whether notifications should be delivered for the initial incident update", - }, - "scheduled_for": { - "type": "string", - "format": "iso8601", - "title": "Time the scheduled maintenance should begin", - }, - "scheduled_until": { - "type": "string", - "format": "iso8601", - "title": "Time the scheduled maintenance should end", - }, - "scheduled_remind_prior": { - "type": "boolean", - "title": "Remind subscribers 60 minutes before scheduled start", - }, - "scheduled_auto_in_progress": { - "type": "boolean", - "title": "Automatically transition incident to 'In Progress' at start", - }, - "scheduled_auto_completed": { - "type": "boolean", - "title": "Automatically transition incident to 'Completed' at end", - }, - "backfilled": {"type": "boolean", "title": "Create an historical incident"}, - "backfill_date": { - "format": "date", - "type": "string", - "title": "Date of incident in YYYY-MM-DD format", - }, - }, - "dependencies": { - "backfill_date": ["backfilled"], - "backfilled": ["backfill_date"], - "scheduled_for": ["scheduled_until"], - "scheduled_until": ["scheduled_for"], - "scheduled_remind_prior": ["scheduled_for"], - "scheduled_auto_in_progress": ["scheduled_for"], - "scheduled_auto_completed": ["scheduled_for"], - }, - "additionalProperties": False, - } - - def _validate_data_dependencies(self, data: dict) -> dict: - scheduled_properties = [prop for prop in data if prop.startswith("scheduled")] - scheduled = any(data.get(prop) is not None for prop in scheduled_properties) - - backfill_properties = [prop for prop in data if prop.startswith("backfill")] - backfill = any(data.get(prop) is not None for prop in backfill_properties) - - if scheduled and backfill: - raise BadArguments( - provider=self.name, - validation_error="Cannot set both 'backfill' and 'scheduled' incident properties " - "in the same notification!", - ) - - status = data.get("status") - if scheduled and status and status not in self.scheduled_statuses: - raise BadArguments( - provider=self.name, - validation_error=f"Status '{status}' is a realtime incident status! " - f"Please choose one of {self.scheduled_statuses}", - ) - elif backfill and status: - raise BadArguments( - provider=self.name, - validation_error="Cannot set 'status' when setting 'backfill'!", - ) - - return data - - def _prepare_data(self, data: dict) -> dict: - new_data = { - "incident[name]": data.pop("message"), - "api_key": data.pop("api_key"), - "page_id": data.pop("page_id"), - } - for key, value in data.items(): - if isinstance(value, bool): - value = "t" if value else "f" - new_data[f"incident[{key}]"] = value - return new_data - - def _send_notification(self, data: dict) -> Response: - url = self.base_url.format(page_id=data.pop("page_id")) + self.incidents_url - params = {"api_key": data.pop("api_key")} + def _send_notification(self, data: StatuspageSchema) -> Response: + data = data.to_dict() + url = urljoin( + self.base_url.format(page_id=data.pop("page_id")), self.incidents_url + ) + headers = self.request_headers(data.pop("api_key")) response, errors = requests.post( - url, data=data, params=params, path_to_errors=self.path_to_errors + url, data=data, headers=headers, path_to_errors=self.path_to_errors ) return self.create_response(data, response, errors) From 3240aa47bac2a089f6e8b164365bd771294c46a1 Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 5 Mar 2020 01:25:37 +0200 Subject: [PATCH 063/137] typo --- notifiers/providers/statuspage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/notifiers/providers/statuspage.py b/notifiers/providers/statuspage.py index 5b94f745..a51a8651 100644 --- a/notifiers/providers/statuspage.py +++ b/notifiers/providers/statuspage.py @@ -180,7 +180,7 @@ class Config: class StatuspageMixin: """Shared resources between :class:`Statuspage` and :class:`StatuspageComponents`""" - base_url = "https://api.statuspage.io/v1//pages/{page_id}/" + base_url = "https://api.statuspage.io/v1/pages/{page_id}/" name = "statuspage" path_to_errors = ("error",) site_url = "https://statuspage.io" From 15c44defa13eb0c9a4fda1c9076ac942abcfb4ef Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 5 Mar 2020 17:30:31 +0200 Subject: [PATCH 064/137] completed statuspage --- notifiers/models/provider.py | 25 ++++++++++++++++---- notifiers/providers/__init__.py | 18 +++++++------- notifiers/providers/statuspage.py | 39 +++++++++++-------------------- 3 files changed, 42 insertions(+), 40 deletions(-) diff --git a/notifiers/models/provider.py b/notifiers/models/provider.py index fc3453bf..a67d525b 100644 --- a/notifiers/models/provider.py +++ b/notifiers/models/provider.py @@ -25,25 +25,40 @@ class SchemaModel(BaseModel): @staticmethod def to_list(value: Union[Any, List[Any]]) -> List[Any]: + """Helper method to make sure a return value is a list""" if not isinstance(value, list): return [value] return value @staticmethod def to_comma_separated(values: Union[Any, List[Any]]) -> str: + """Helper method that return a comma separates string from a value""" if not isinstance(values, list): values = [values] return ",".join(str(value) for value in values) @staticmethod - def single_or_list(type_): + def single_or_list(type_: Any) -> Union[List[Any], Any]: + """A helper method that returns the relevant type to specify that one or more the given type can be used + in a schema""" return Union[List[type_], type_] - def to_dict(self, exclude_none: bool = True, by_alias: bool = True) -> dict: - """A helper method to a very common dict builder. + def to_dict( + self, exclude_none: bool = True, by_alias: bool = True, **kwargs + ) -> dict: + """ + A helper method to a very common dict builder. Round tripping to json and back to dict is needed since the model can contain special object that need - to be transformed to json first (like enums)""" - return json.loads(self.json(exclude_none=exclude_none, by_alias=by_alias)) + to be transformed to json first (like enums) + + :param exclude_none: Should values that are `None` be part of the payload + :param by_alias: Use the field name of its alias name (if exists) + :param kwargs: Additional options. See https://pydantic-docs.helpmanual.io/usage/exporting_models/ + :return: dict payload of the schema + """ + return json.loads( + self.json(exclude_none=exclude_none, by_alias=by_alias, **kwargs) + ) class Config: allow_population_by_field_name = True diff --git a/notifiers/providers/__init__.py b/notifiers/providers/__init__.py index 1c54d69d..96c32c14 100644 --- a/notifiers/providers/__init__.py +++ b/notifiers/providers/__init__.py @@ -16,19 +16,19 @@ from .slack import Slack _all_providers = { - # "pushover": pushover.Pushover, - # "simplepush": simplepush.SimplePush, + "pushover": pushover.Pushover, + "simplepush": simplepush.SimplePush, "slack": Slack, "email": email.SMTP, "gmail": gmail.Gmail, - # "telegram": telegram.Telegram, + "telegram": telegram.Telegram, "gitter": gitter.Gitter, "pushbullet": pushbullet.Pushbullet, - # "join": join.Join, - # "zulip": zulip.Zulip, - # "twilio": twilio.Twilio, - # "pagerduty": pagerduty.PagerDuty, + "join": join.Join, + "zulip": zulip.Zulip, + "twilio": twilio.Twilio, + "pagerduty": pagerduty.PagerDuty, "mailgun": mailgun.MailGun, - # "popcornnotify": popcornnotify.PopcornNotify, - # "statuspage": statuspage.Statuspage, + "popcornnotify": popcornnotify.PopcornNotify, + "statuspage": statuspage.Statuspage, } diff --git a/notifiers/providers/statuspage.py b/notifiers/providers/statuspage.py index a51a8651..c83580ee 100644 --- a/notifiers/providers/statuspage.py +++ b/notifiers/providers/statuspage.py @@ -193,25 +193,13 @@ def request_headers(api_key: str) -> dict: class StatuspageComponents(StatuspageMixin, ProviderResource): """Return a list of Statuspage components for the page ID""" - resource_name = "components" - components_url = "components" - - _required = {"required": ["api_key", "page_id"]} - - _schema = { - "type": "object", - "properties": { - "api_key": {"type": "string", "title": "OAuth2 token"}, - "page_id": {"type": "string", "title": "Page ID"}, - }, - "additionalProperties": False, - } - - def _get_resource(self, data: dict) -> dict: - url = urljoin( - self.base_url.format(page_id=data["page_id"]), self.components_url - ) - headers = self.request_headers(data.pop("api_key")) + resource_name = components_url = "components" + + schema_model = StatuspageBaseSchema + + def _get_resource(self, data: StatuspageBaseSchema) -> dict: + url = urljoin(self.base_url.format(page_id=data.page_id), self.components_url) + headers = self.request_headers(data.api_key) response, errors = requests.get( url, headers=headers, path_to_errors=self.path_to_errors ) @@ -236,12 +224,11 @@ class Statuspage(StatuspageMixin, Provider): schema_model = StatuspageSchema def _send_notification(self, data: StatuspageSchema) -> Response: - data = data.to_dict() - url = urljoin( - self.base_url.format(page_id=data.pop("page_id")), self.incidents_url - ) - headers = self.request_headers(data.pop("api_key")) + url = urljoin(self.base_url.format(page_id=data.page_id), self.incidents_url) + headers = self.request_headers(data.api_key) + data_dict = data.to_dict(exclude={"page_id", "api_key"}) + payload = {"incident": data_dict} response, errors = requests.post( - url, data=data, headers=headers, path_to_errors=self.path_to_errors + url, json=payload, headers=headers, path_to_errors=self.path_to_errors ) - return self.create_response(data, response, errors) + return self.create_response(data_dict, response, errors) From dafbf92563a009c63fcb0cc0cb2c86a6bea408f3 Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 5 Mar 2020 17:31:10 +0200 Subject: [PATCH 065/137] renamed single_or_list to one_or_more_of --- notifiers/models/provider.py | 4 ++-- notifiers/providers/email.py | 6 +++--- notifiers/providers/join.py | 4 ++-- notifiers/providers/mailgun.py | 12 ++++++------ notifiers/providers/popcornnotify.py | 2 +- notifiers/providers/pushover.py | 6 +++--- 6 files changed, 17 insertions(+), 17 deletions(-) diff --git a/notifiers/models/provider.py b/notifiers/models/provider.py index a67d525b..2ee13c5b 100644 --- a/notifiers/models/provider.py +++ b/notifiers/models/provider.py @@ -38,8 +38,8 @@ def to_comma_separated(values: Union[Any, List[Any]]) -> str: return ",".join(str(value) for value in values) @staticmethod - def single_or_list(type_: Any) -> Union[List[Any], Any]: - """A helper method that returns the relevant type to specify that one or more the given type can be used + def one_or_more_of(type_: Any) -> Union[List[Any], Any]: + """A helper method that returns the relevant type to specify that one or more of the given type can be used in a schema""" return Union[List[type_], type_] diff --git a/notifiers/providers/email.py b/notifiers/providers/email.py index 50c5915c..537f97d9 100644 --- a/notifiers/providers/email.py +++ b/notifiers/providers/email.py @@ -27,16 +27,16 @@ class SMTPSchema(SchemaModel): subject: str = Field( "New email from 'notifiers'!", description="The subject of the email message" ) - to: SchemaModel.single_or_list(EmailStr) = Field( + to: SchemaModel.one_or_more_of(EmailStr) = Field( ..., description="One or more email addresses to use" ) - from_: SchemaModel.single_or_list(EmailStr) = Field( + from_: SchemaModel.one_or_more_of(EmailStr) = Field( f"{getpass.getuser()}@{socket.getfqdn()}", description="One or more FROM addresses to use", alias="from", title="from", ) - attachments: SchemaModel.single_or_list(FilePath) = Field( + attachments: SchemaModel.one_or_more_of(FilePath) = Field( None, description="One or more attachments to use in the email" ) host: str = Field("localhost", description="The host of the SMTP server") diff --git a/notifiers/providers/join.py b/notifiers/providers/join.py index a0c2c4c5..94202d40 100644 --- a/notifiers/providers/join.py +++ b/notifiers/providers/join.py @@ -46,12 +46,12 @@ class JoinSchema(JoinBaseSchema): description="The device ID or group ID of the device you want to send the message to", alias="deviceId", ) - device_ids: SchemaModel.single_or_list(str) = Field( + device_ids: SchemaModel.one_or_more_of(str) = Field( None, description="A comma separated list of device IDs you want to send the push to", alias="deviceIds", ) - device_names: SchemaModel.single_or_list(str) = Field( + device_names: SchemaModel.one_or_more_of(str) = Field( None, description="A comma separated list of device names you want to send the push to", alias="deviceNames", diff --git a/notifiers/providers/mailgun.py b/notifiers/providers/mailgun.py index 98975d72..b5d318e5 100644 --- a/notifiers/providers/mailgun.py +++ b/notifiers/providers/mailgun.py @@ -27,13 +27,13 @@ class MailGunSchema(SchemaModel): from_: NameEmail = Field( ..., description="Email address for 'From' header", alias="from" ) - to: SchemaModel.single_or_list(Union[EmailStr, NameEmail]) = Field( + to: SchemaModel.one_or_more_of(Union[EmailStr, NameEmail]) = Field( ..., description="Email address of the recipient(s)" ) - cc: SchemaModel.single_or_list(NameEmail) = Field( + cc: SchemaModel.one_or_more_of(NameEmail) = Field( None, description="Email address of the CC recipient(s)" ) - bcc: SchemaModel.single_or_list(NameEmail) = Field( + bcc: SchemaModel.one_or_more_of(NameEmail) = Field( None, description="Email address of the BCC recipient(s)" ) subject: str = Field(None, description="Message subject") @@ -46,10 +46,10 @@ class MailGunSchema(SchemaModel): description="AMP part of the message. Please follow google guidelines to compose and send AMP emails.", alias="amp-html", ) - attachment: SchemaModel.single_or_list(FilePath) = Field( + attachment: SchemaModel.one_or_more_of(FilePath) = Field( None, description="File attachment(s)" ) - inline: SchemaModel.single_or_list(FilePath) = Field( + inline: SchemaModel.one_or_more_of(FilePath) = Field( None, description="Attachment with inline disposition. Can be used to send inline images", ) @@ -123,7 +123,7 @@ class MailGunSchema(SchemaModel): alias="o:skip-verification", ) - headers: SchemaModel.single_or_list(Dict[str, str]) = Field( + headers: SchemaModel.one_or_more_of(Dict[str, str]) = Field( None, description="Add arbitrary value(s) to append a custom MIME header to the message", ) diff --git a/notifiers/providers/popcornnotify.py b/notifiers/providers/popcornnotify.py index 98c882f8..af87b534 100644 --- a/notifiers/providers/popcornnotify.py +++ b/notifiers/providers/popcornnotify.py @@ -14,7 +14,7 @@ class PopcornNotifySchema(SchemaModel): None, description="The subject of the email. It will not be included in text messages", ) - recipients: SchemaModel.single_or_list(str) = Field( + recipients: SchemaModel.one_or_more_of(str) = Field( ..., description="The recipient email address or phone number.Or an array of email addresses and phone numbers", ) diff --git a/notifiers/providers/pushover.py b/notifiers/providers/pushover.py index 19d2c87d..06838616 100644 --- a/notifiers/providers/pushover.py +++ b/notifiers/providers/pushover.py @@ -47,14 +47,14 @@ class PushoverBaseSchema(SchemaModel): class PushoverSchema(PushoverBaseSchema): - user: PushoverBaseSchema.single_or_list(str) = Field( + user: PushoverBaseSchema.one_or_more_of(str) = Field( ..., description="The user/group key (not e-mail address) of your user (or you)" ) message: str = Field(..., description="Your message") attachment: FilePath = Field( None, description="An image attachment to send with the message" ) - device: PushoverBaseSchema.single_or_list(str) = Field( + device: PushoverBaseSchema.one_or_more_of(str) = Field( None, description="Your user's device name to send the message directly to that device," " rather than all of the user's devices", @@ -102,7 +102,7 @@ class PushoverSchema(PushoverBaseSchema): description="A publicly-accessible URL that our servers will send a request to when the user has" " acknowledged your notification. requires setting priorty to 2", ) - tags: PushoverBaseSchema.single_or_list(str) = Field( + tags: PushoverBaseSchema.one_or_more_of(str) = Field( None, description="Arbitrary tags which will be stored with the receipt on our servers", ) From 6d9f372ab293c252ba57e7468b019ec845009b81 Mon Sep 17 00:00:00 2001 From: liiight Date: Fri, 6 Mar 2020 01:13:18 +0200 Subject: [PATCH 066/137] added telegram schema --- notifiers/providers/telegram.py | 306 ++++++++++++++++++++++++++------ 1 file changed, 251 insertions(+), 55 deletions(-) diff --git a/notifiers/providers/telegram.py b/notifiers/providers/telegram.py index c85833ef..c763ad8d 100644 --- a/notifiers/providers/telegram.py +++ b/notifiers/providers/telegram.py @@ -1,10 +1,252 @@ +from enum import Enum +from typing import List +from typing import Union +from urllib.parse import urljoin + +from pydantic import Field +from pydantic import HttpUrl +from pydantic import root_validator + from ..exceptions import ResourceError from ..models.provider import Provider from ..models.provider import ProviderResource +from ..models.provider import SchemaModel from ..models.response import Response from ..utils import requests +class TelegramURL(HttpUrl): + allowed_schemes = "http", "https", "tg" + + +class LoginUrl(SchemaModel): + """This object represents a parameter of the inline keyboard button used to automatically authorize a user. + Serves as a great replacement for the Telegram Login Widget when the user is coming from Telegram. + All the user needs to do is tap/click a button and confirm that they want to log in""" + + url: HttpUrl = Field( + ..., + description="An HTTP URL to be opened with user authorization data added to the query string when the" + " button is pressed. If the user refuses to provide authorization data, the original URL without" + " information about the user will be opened. The data added is the same as described in" + " Receiving authorization data", + ) + forward_text: str = Field( + None, description="New text of the button in forwarded messages" + ) + bot_username: str = Field( + None, + description="Username of a bot, which will be used for user authorization. See Setting up a bot for" + " more details. If not specified, the current bot's username will be assumed." + " The url's domain must be the same as the domain linked with the bot", + ) + request_write_access: bool = Field( + None, + description="Pass True to request the permission for your bot to send messages to the user", + ) + + +class InlineKeyboardButton(SchemaModel): + """This object represents one button of an inline keyboard. You must use exactly one of the optional fields""" + + text: str = Field(..., description="Label text on the button") + url: TelegramURL = Field( + None, description="HTTP or tg:// url to be opened when button is pressed" + ) + login_url: LoginUrl = Field( + None, + description="An HTTP URL used to automatically authorize the user. Can be used as a replacement for the" + " Telegram Login Widget", + ) + callback_data: str = Field( + None, + description="Data to be sent in a callback query to the bot when button is pressed", + ) + switch_inline_query: str = Field( + None, + description="If set, pressing the button will prompt the user to select one of their chats, " + "open that chat and insert the bot‘s username and the specified inline query in the input field." + " Can be empty, in which case just the bot’s username will be inserted", + ) + switch_inline_query_current_chat: str = Field( + None, + description="If set, pressing the button will insert the bot‘s username and the specified inline query in the" + " current chat's input field. Can be empty, in which case only the bot’s username will be inserted", + ) + callback_game: str = Field( + None, + description="Description of the game that will be launched when the user presses the button", + ) + pay: bool = Field(None, description="Specify True, to send a Pay button") + + @root_validator + def only_one_optional(cls, values): + values_items = set(values.keys()) + values_items.remove("text") + if len(values_items) > 1: + raise ValueError("You must use exactly one of the optional fields") + return values + + +class KeyboardButtonPollType(SchemaModel): + type: str = Field( + None, + description="If quiz is passed, the user will be allowed to create only polls in the quiz mode." + " If regular is passed, only regular polls will be allowed. Otherwise, the user will be allowed" + " to create a poll of any type", + ) + + +class KeyboardButton(SchemaModel): + """This object represents one button of the reply keyboard. For simple text buttons String can be + used instead of this object to specify text of the button. Optional fields request_contact, + request_location, and request_poll are mutually exclusive""" + + text: str = Field( + ..., + description="Text of the button. If none of the optional fields are used, it will be sent as a message " + "when the button is pressed", + ) + request_contact: bool = Field( + None, + description="If True, the user's phone number will be sent as a contact when the button is pressed." + " Available in private chats only", + ) + request_location: bool = Field( + None, + description="If True, the user's current location will be sent when the button is pressed." + " Available in private chats only", + ) + request_poll: KeyboardButtonPollType = Field( + None, + description="If specified, the user will be asked to create a poll and send it to the bot when the button " + "is pressed. Available in private chats only", + ) + + +class InlineKeyboardMarkup(SchemaModel): + """This object represents an inline keyboard that appears right next to the message it belongs to""" + + inline_keyboard: List[List[InlineKeyboardButton]] = Field( + ..., + description="Array of button rows, each represented by an Array of InlineKeyboardButton objects", + ) + + +class ReplyKeyboardMarkup(SchemaModel): + """This object represents a custom keyboard with reply options + (see Introduction to bots for details and examples)""" + + keyboard: List[List[KeyboardButton]] = Field( + ..., + description="Array of button rows, each represented by an Array of KeyboardButton objects", + ) + resize_keyboard: bool = Field( + None, + description="Requests clients to resize the keyboard vertically for optimal fit " + "(e.g., make the keyboard smaller if there are just two rows of buttons)." + " Defaults to false, in which case the custom keyboard is always of the same" + " height as the app's standard keyboard", + ) + one_time_keyboard: bool = Field( + None, + description="Requests clients to hide the keyboard as soon as it's been used." + " The keyboard will still be available, but clients will automatically display the usual " + "letter-keyboard in the chat – the user can press a special button in the input field to see " + "the custom keyboard again. Defaults to false", + ) + selective: bool = Field( + None, + description="Use this parameter if you want to show the keyboard to specific users only. Targets: 1)" + " users that are @mentioned in the text of the Message object; 2) if the bot's message is a " + "reply (has reply_to_message_id), sender of the original message. Example: A user requests to " + "change the bot‘s language, bot replies to the request with a keyboard to select the new language." + " Other users in the group don’t see the keyboard", + ) + + +class ReplyKeyboardRemove(SchemaModel): + """Upon receiving a message with this object, Telegram clients will remove the current custom keyboard and + display the default letter-keyboard. By default, custom keyboards are displayed until a new keyboard is sent by + a bot. An exception is made for one-time keyboards that are hidden immediately after the user presses a button + (see ReplyKeyboardMarkup).""" + + remove_keyboard: bool = Field( + ..., + description="Requests clients to remove the custom keyboard (user will not be able to summon this keyboard; " + "if you want to hide the keyboard from sight but keep it accessible, use one_time_keyboard in" + " ReplyKeyboardMarkup", + ) + selective: bool = Field( + None, + description="Use this parameter if you want to remove the keyboard for specific users only." + " Targets: 1) users that are @mentioned in the text of the Message object; " + "2) if the bot's message is a reply (has reply_to_message_id)," + " sender of the original message. Example: A user votes in a poll," + " bot returns confirmation message in reply to the vote and removes the keyboard for that user," + " while still showing the keyboard with poll options to users who haven't voted yet", + ) + + +class ForceReply(SchemaModel): + """Upon receiving a message with this object, Telegram clients will display a reply interface to the user + (act as if the user has selected the bot‘s message and tapped ’Reply'). + This can be extremely useful if you want to create user-friendly step-by-step interfaces without having + to sacrifice privacy mode.""" + + force_reply: bool = Field( + ..., + description="Shows reply interface to the user, as if they manually selected the bot‘s message and" + " tapped ’Reply'", + ) + selective: bool = Field( + None, + description="Use this parameter if you want to force reply from specific users only. " + "Targets: 1) users that are @mentioned in the text of the Message object; " + "2) if the bot's message is a reply (has reply_to_message_id), sender of the original message", + ) + + +class ParseMode(Enum): + markdown = "Markdown" + html = "HTML" + markdown_v2 = "MarkdownV2" + + +class TelegramBaseSchema(SchemaModel): + token: str = Field(..., description="Bot token") + + +class TelegramSchema(TelegramBaseSchema): + message: str = Field( + ..., + description="Text of the message to be sent, 1-4096 characters after entities parsing", + alias="text", + ) + parse_mode: ParseMode = Field( + None, + description="Send Markdown or HTML, if you want Telegram apps to show bold, italic, " + "fixed-width text or inline URLs in your bot's message.", + ) + disable_web_page_preview: bool = Field( + None, description="Disables link previews for links in this message" + ) + disable_notification: bool = Field( + None, + description="Sends the message silently. Users will receive a notification with no sound", + ) + reply_to_message_id: int = Field( + None, description="If the message is a reply, ID of the original message" + ) + reply_markup: Union[ + InlineKeyboardMarkup, ReplyKeyboardMarkup, ReplyKeyboardRemove, ForceReply + ] = Field( + None, + description="Additional interface options. A JSON-serialized object for an inline keyboard," + " custom reply keyboard, instructions to remove reply keyboard or to force a reply from the user", + ) + + class TelegramMixin: """Shared resources between :class:`TelegramUpdates` and :class:`Telegram`""" @@ -17,18 +259,10 @@ class TelegramUpdates(TelegramMixin, ProviderResource): """Return Telegram bot updates, correlating to the `getUpdates` method. Returns chat IDs needed to notifications""" resource_name = "updates" - updates_endpoint = "/getUpdates" - - _required = {"required": ["token"]} + schema_model = TelegramBaseSchema - _schema = { - "type": "object", - "properties": {"token": {"type": "string", "title": "Bot token"}}, - "additionalProperties": False, - } - - def _get_resource(self, data: dict) -> list: - url = self.base_url.format(token=data["token"]) + self.updates_endpoint + def _get_resource(self, data: TelegramBaseSchema) -> list: + url = urljoin(self.base_url.format(token=data.token), "/getUpdates") response, errors = requests.get(url, path_to_errors=self.path_to_errors) if errors: raise ResourceError( @@ -45,51 +279,13 @@ class Telegram(TelegramMixin, Provider): """Send Telegram notifications""" site_url = "https://core.telegram.org/" - push_endpoint = "/sendMessage" - _resources = {"updates": TelegramUpdates()} + schema_model = TelegramSchema - _required = {"required": ["message", "chat_id", "token"]} - _schema = { - "type": "object", - "properties": { - "message": {"type": "string", "title": "Text of the message to be sent"}, - "token": {"type": "string", "title": "Bot token"}, - "chat_id": { - "oneOf": [{"type": "string"}, {"type": "integer"}], - "title": "Unique identifier for the target chat or username of the target channel " - "(in the format @channelusername)", - }, - "parse_mode": { - "type": "string", - "title": "Send Markdown or HTML, if you want Telegram apps to show bold, italic," - " fixed-width text or inline URLs in your bot's message.", - "enum": ["markdown", "html"], - }, - "disable_web_page_preview": { - "type": "boolean", - "title": "Disables link previews for links in this message", - }, - "disable_notification": { - "type": "boolean", - "title": "Sends the message silently. Users will receive a notification with no sound.", - }, - "reply_to_message_id": { - "type": "integer", - "title": "If the message is a reply, ID of the original message", - }, - }, - "additionalProperties": False, - } - - def _prepare_data(self, data: dict) -> dict: - data["text"] = data.pop("message") - return data - - def _send_notification(self, data: dict) -> Response: - token = data.pop("token") - url = self.base_url.format(token=token) + self.push_endpoint + def _send_notification(self, data: TelegramSchema) -> Response: + url = urljoin(self.base_url.format(token=data.token), "/sendMessage") + payload = data.to_dict(exclude={"token"}) response, errors = requests.post( - url, json=data, path_to_errors=self.path_to_errors + url, json=payload, path_to_errors=self.path_to_errors ) - return self.create_response(data, response, errors) + return self.create_response(payload, response, errors) From 01df67c1f1cdc942f346cd41defdea859cddf4fd Mon Sep 17 00:00:00 2001 From: liiight Date: Fri, 6 Mar 2020 01:43:06 +0200 Subject: [PATCH 067/137] added chat id to schema --- notifiers/providers/telegram.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/notifiers/providers/telegram.py b/notifiers/providers/telegram.py index c763ad8d..51c8f44d 100644 --- a/notifiers/providers/telegram.py +++ b/notifiers/providers/telegram.py @@ -223,6 +223,11 @@ class TelegramSchema(TelegramBaseSchema): description="Text of the message to be sent, 1-4096 characters after entities parsing", alias="text", ) + chat_id: Union[int, str] = Field( + ..., + description="Unique identifier for the target chat or username of the target" + " channel (in the format @channelusername", + ) parse_mode: ParseMode = Field( None, description="Send Markdown or HTML, if you want Telegram apps to show bold, italic, " @@ -250,7 +255,7 @@ class TelegramSchema(TelegramBaseSchema): class TelegramMixin: """Shared resources between :class:`TelegramUpdates` and :class:`Telegram`""" - base_url = "https://api.telegram.org/bot{token}" + base_url = "https://api.telegram.org/bot{token}/" name = "telegram" path_to_errors = ("description",) @@ -262,7 +267,7 @@ class TelegramUpdates(TelegramMixin, ProviderResource): schema_model = TelegramBaseSchema def _get_resource(self, data: TelegramBaseSchema) -> list: - url = urljoin(self.base_url.format(token=data.token), "/getUpdates") + url = urljoin(self.base_url.format(token=data.token), "getUpdates") response, errors = requests.get(url, path_to_errors=self.path_to_errors) if errors: raise ResourceError( @@ -283,7 +288,7 @@ class Telegram(TelegramMixin, Provider): schema_model = TelegramSchema def _send_notification(self, data: TelegramSchema) -> Response: - url = urljoin(self.base_url.format(token=data.token), "/sendMessage") + url = urljoin(self.base_url.format(token=data.token), "sendMessage") payload = data.to_dict(exclude={"token"}) response, errors = requests.post( url, json=payload, path_to_errors=self.path_to_errors From 85bf96b9faf15fe62f50a28f319000fc2ffc376b Mon Sep 17 00:00:00 2001 From: liiight Date: Fri, 6 Mar 2020 02:01:07 +0200 Subject: [PATCH 068/137] tweaked error --- notifiers/providers/telegram.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/notifiers/providers/telegram.py b/notifiers/providers/telegram.py index 51c8f44d..a5409642 100644 --- a/notifiers/providers/telegram.py +++ b/notifiers/providers/telegram.py @@ -81,10 +81,11 @@ class InlineKeyboardButton(SchemaModel): @root_validator def only_one_optional(cls, values): - values_items = set(values.keys()) - values_items.remove("text") + values_items = [v for v in values if values.get(v) and v != "text"] if len(values_items) > 1: - raise ValueError("You must use exactly one of the optional fields") + raise ValueError( + f"You must use exactly one of the optional fields, more than one were passed: {','.join(values_items)}" + ) return values @@ -218,6 +219,8 @@ class TelegramBaseSchema(SchemaModel): class TelegramSchema(TelegramBaseSchema): + """Telegram message sending schema""" + message: str = Field( ..., description="Text of the message to be sent, 1-4096 characters after entities parsing", @@ -251,6 +254,9 @@ class TelegramSchema(TelegramBaseSchema): " custom reply keyboard, instructions to remove reply keyboard or to force a reply from the user", ) + class Config: + json_encoders = {ParseMode: lambda v: v.value} + class TelegramMixin: """Shared resources between :class:`TelegramUpdates` and :class:`Telegram`""" From 5480f25d1dc06955d32c48857cbd36c07b97bb04 Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 8 Mar 2020 14:15:04 +0200 Subject: [PATCH 069/137] tweaks --- notifiers/providers/email.py | 25 +++++++++++++------------ tests/providers/test_smtp.py | 2 +- 2 files changed, 14 insertions(+), 13 deletions(-) diff --git a/notifiers/providers/email.py b/notifiers/providers/email.py index 537f97d9..fcdb50b8 100644 --- a/notifiers/providers/email.py +++ b/notifiers/providers/email.py @@ -23,6 +23,8 @@ class SMTPSchema(SchemaModel): + """SMTP email schema""" + message: str = Field(..., description="The content of the email message") subject: str = Field( "New email from 'notifiers'!", description="The subject of the email message" @@ -37,7 +39,7 @@ class SMTPSchema(SchemaModel): title="from", ) attachments: SchemaModel.one_or_more_of(FilePath) = Field( - None, description="One or more attachments to use in the email" + [], description="One or more attachments to use in the email" ) host: str = Field("localhost", description="The host of the SMTP server") port: int = Field(25, gt=0, lte=65535, description="The port number to use") @@ -99,7 +101,7 @@ def _build_email(data: SMTPSchema) -> EmailMessage: email.add_alternative(data.message, subtype=content_type) return email - def _add_attachments(self, attachments: List[Path], email: EmailMessage): + def add_attachments_to_email(self, attachments: List[Path], email: EmailMessage): for attachment in attachments: maintype, subtype = self._get_mimetype(attachment) email.add_attachment( @@ -110,9 +112,9 @@ def _add_attachments(self, attachments: List[Path], email: EmailMessage): ) def _connect_to_server(self, data: SMTPSchema): - self.smtp_server = smtplib.SMTP_SSL if data.ssl else smtplib.SMTP - self.smtp_server = self.smtp_server(data.host, data.port) - self.configuration = self._get_configuration(data) + smtp_server_type = smtplib.SMTP_SSL if data.ssl else smtplib.SMTP + self.smtp_server = smtp_server_type(data.host, data.port) + self.configuration_hash = self.configuration_hash(data) if data.tls and not data.ssl: self.smtp_server.ehlo() self.smtp_server.starttls() @@ -121,22 +123,21 @@ def _connect_to_server(self, data: SMTPSchema): self.smtp_server.login(data.username, data.password) @staticmethod - def _get_configuration(data: SMTPSchema) -> tuple: - return data.host, data.port, data.username + def configuration_hash(data: SMTPSchema) -> int: + return hash((data.host, data.port, data.username)) def _send_notification(self, data: SMTPSchema) -> Response: errors = None try: - configuration = self._get_configuration(data) + configuration_hash = self.configuration_hash(data) if ( - not self.configuration + not self.configuration_hash or not self.smtp_server - or self.configuration != configuration + or self.configuration_hash != configuration_hash ): self._connect_to_server(data) email = self._build_email(data) - if data.attachments: - self._add_attachments(data.attachments, email) + self.add_attachments_to_email(data.attachments, email) self.smtp_server.send_message(email) except ( SMTPServerDisconnected, diff --git a/tests/providers/test_smtp.py b/tests/providers/test_smtp.py index 35c499d9..e00ce1f7 100644 --- a/tests/providers/test_smtp.py +++ b/tests/providers/test_smtp.py @@ -95,7 +95,7 @@ def test_attachment_mimetypes(self, provider, tmpdir): file_3.write("foo") attachments = [str(file_1), str(file_2), str(file_3)] email = EmailMessage() - provider._add_attachments(attachments=attachments, email=email) + provider.add_attachments_to_email(attachments=attachments, email=email) attach1, attach2, attach3 = email.iter_attachments() assert attach1.get_content_type() == "text/plain" assert attach2.get_content_type() == "image/jpeg" From 322c5eacc971f2864cc877ff94343791cbf82634 Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 8 Mar 2020 14:24:42 +0200 Subject: [PATCH 070/137] more tweaks --- notifiers/providers/email.py | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/notifiers/providers/email.py b/notifiers/providers/email.py index fcdb50b8..f72de64c 100644 --- a/notifiers/providers/email.py +++ b/notifiers/providers/email.py @@ -64,6 +64,11 @@ def values_to_list(cls, v): def comma_separated(cls, v): return cls.to_comma_separated(v) + @property + def hash(self): + """Returns a hash value of host, port and username to check if configuration changed""" + return hash((self.host, self.port, self.username)) + class SMTP(Provider): """Send emails via SMTP""" @@ -88,7 +93,7 @@ def _get_mimetype(attachment: Path) -> Tuple[str, str]: def __init__(self): super().__init__() self.smtp_server = None - self.configuration = None + self.configuration_hash = None @staticmethod def _build_email(data: SMTPSchema) -> EmailMessage: @@ -114,7 +119,7 @@ def add_attachments_to_email(self, attachments: List[Path], email: EmailMessage) def _connect_to_server(self, data: SMTPSchema): smtp_server_type = smtplib.SMTP_SSL if data.ssl else smtplib.SMTP self.smtp_server = smtp_server_type(data.host, data.port) - self.configuration_hash = self.configuration_hash(data) + self.configuration_hash = data.hash if data.tls and not data.ssl: self.smtp_server.ehlo() self.smtp_server.starttls() @@ -122,19 +127,15 @@ def _connect_to_server(self, data: SMTPSchema): if data.login and data.username: self.smtp_server.login(data.username, data.password) - @staticmethod - def configuration_hash(data: SMTPSchema) -> int: - return hash((data.host, data.port, data.username)) - def _send_notification(self, data: SMTPSchema) -> Response: errors = None + connection_conditions = ( + not self.smtp_server, + not self.configuration_hash, + self.configuration_hash != data.hash, + ) try: - configuration_hash = self.configuration_hash(data) - if ( - not self.configuration_hash - or not self.smtp_server - or self.configuration_hash != configuration_hash - ): + if any(connection_conditions): self._connect_to_server(data) email = self._build_email(data) self.add_attachments_to_email(data.attachments, email) From 9179a4ae491a29170234120111a51dcf0aedd01f Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 8 Mar 2020 14:42:40 +0200 Subject: [PATCH 071/137] gitter tweaks --- notifiers/providers/gitter.py | 46 +++++++++++++++-------------------- 1 file changed, 20 insertions(+), 26 deletions(-) diff --git a/notifiers/providers/gitter.py b/notifiers/providers/gitter.py index 95703a1d..f408afa8 100644 --- a/notifiers/providers/gitter.py +++ b/notifiers/providers/gitter.py @@ -13,14 +13,25 @@ class GitterSchemaBase(SchemaModel): token: str = Field(..., description="Access token") + @property + def auth_header(self) -> dict: + return {"Authorization": f"Bearer {self.token}"} + class GitterRoomSchema(GitterSchemaBase): + """List rooms the current user is in""" + filter: str = Field(None, description="Filter results") class GitterSchema(GitterSchemaBase): - text: str = Field(..., description="Body of the message", alias="message") + """Send a message to a room""" + + message: str = Field(..., description="Body of the message", alias="text") room_id: str = Field(..., description="ID of the room to send the notification to") + status: bool = Field( + None, description="set to true to indicate that the message is a status update" + ) class GitterMixin: @@ -31,16 +42,6 @@ class GitterMixin: path_to_errors = "errors", "error" base_url = "https://api.gitter.im/v1/rooms" - @staticmethod - def _get_headers(token: str) -> dict: - """ - Builds Gitter requests header bases on the token provided - - :param token: App token - :return: Authentication header dict - """ - return {"Authorization": f"Bearer {token}"} - class GitterRooms(GitterMixin, ProviderResource): """Returns a list of Gitter rooms via token""" @@ -49,14 +50,13 @@ class GitterRooms(GitterMixin, ProviderResource): schema_model = GitterRoomSchema def _get_resource(self, data: GitterRoomSchema) -> list: - headers = self._get_headers(data.token) params = {} if data.filter: params["q"] = data.filter response, errors = requests.get( self.base_url, - headers=headers, + headers=data.auth_header, params=params, path_to_errors=self.path_to_errors, ) @@ -75,25 +75,19 @@ def _get_resource(self, data: GitterRoomSchema) -> list: class Gitter(GitterMixin, Provider): """Send Gitter notifications""" - message_url = "/{room_id}/chatMessages" site_url = "https://gitter.im" schema_model = GitterSchema _resources = {"rooms": GitterRooms()} - @property - def metadata(self) -> dict: - metadata = super().metadata - metadata["message_url"] = self.message_url - return metadata - def _send_notification(self, data: GitterSchema) -> Response: - data = data.to_dict() - room_id = data.pop("room_id") - url = urljoin(self.base_url, self.message_url.format(room_id=room_id)) + url = urljoin(self.base_url, f"/{data.room_id}/chatMessages") - headers = self._get_headers(data.pop("token")) + payload = data.to_dict(include={"message", "status"}) response, errors = requests.post( - url, json=data, headers=headers, path_to_errors=self.path_to_errors + url, + json=payload, + headers=data.auth_header, + path_to_errors=self.path_to_errors, ) - return self.create_response(data, response, errors) + return self.create_response(payload, response, errors) From de2d9e5160881e65713704818baba6328fe809c7 Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 8 Mar 2020 15:07:19 +0200 Subject: [PATCH 072/137] mailgun tweaks --- notifiers/providers/gmail.py | 2 ++ notifiers/providers/mailgun.py | 47 +++++++++++++++++++--------------- 2 files changed, 29 insertions(+), 20 deletions(-) diff --git a/notifiers/providers/gmail.py b/notifiers/providers/gmail.py index cb8256a4..59e05309 100644 --- a/notifiers/providers/gmail.py +++ b/notifiers/providers/gmail.py @@ -6,6 +6,8 @@ class GmailSchema(email.SMTPSchema): + """Gmail email schema""" + host: str = Field(GMAIL_SMTP_HOST, description="The host of the SMTP server") port: int = Field(587, gt=0, lte=65535, description="The port number to use") tls: bool = Field(True, description="Should TLS be used") diff --git a/notifiers/providers/mailgun.py b/notifiers/providers/mailgun.py index b5d318e5..14b1a1a7 100644 --- a/notifiers/providers/mailgun.py +++ b/notifiers/providers/mailgun.py @@ -21,7 +21,24 @@ from ..utils import requests +class Ascii(str): + @classmethod + def __get_validators__(cls): + yield cls.validate + + @classmethod + def validate(cls, v): + if not isinstance(v, str): + raise TypeError("string required") + try: + return cls(v.encode("ascii").decode("ascii")) + except UnicodeEncodeError: + raise ValueError("Value must be valid ascii") + + class MailGunSchema(SchemaModel): + """Send a mailgun email""" + api_key: str = Field(..., description="User's API key") domain: str = Field(..., description="The domain to use") from_: NameEmail = Field( @@ -67,7 +84,7 @@ class MailGunSchema(SchemaModel): " in case of template sending", alias="t:text", ) - tag: List[str] = Field( + tag: List[Ascii] = Field( None, description="Tag string. See Tagging for more information", alias="o:tag", @@ -137,14 +154,6 @@ class MailGunSchema(SchemaModel): alias="recipient-variables", ) - @validator("tag", pre=True, each_item=True) - def validate_tag(cls, v): - try: - v.encode("ascii") - except UnicodeEncodeError: - raise ValueError("Value must be valid ascii") - return v - @root_validator() def headers_and_data(cls, values): def transform(key_name, prefix, json_dump): @@ -206,20 +215,18 @@ class MailGun(Provider): schema_model = MailGunSchema def _send_notification(self, data: MailGunSchema) -> Response: - data = data.dict(by_alias=True, exclude_none=True) - url = self.base_url.format(domain=data.pop("domain")) - auth = "api", data.pop("api_key") + url = self.base_url.format(domain=data.domain) files = [] - if data.get("attachment"): - files += requests.file_list_for_request(data["attachment"], "attachment") - if data.get("inline"): - files += requests.file_list_for_request(data["inline"], "inline") - + if data.attachment: + files += requests.file_list_for_request(data.attachment, "attachment") + if data.inline: + files += requests.file_list_for_request(data.inline, "inline") + payload = data.to_dict(exclude={"domain", "api_key"}) response, errors = requests.post( url=url, - data=data, - auth=auth, + data=payload, + auth=("api", data.api_key), files=files, path_to_errors=self.path_to_errors, ) - return self.create_response(data, response, errors) + return self.create_response(payload, response, errors) From bff9d3e543f12c9a76aab0686dc01ce8bb865cbb Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 8 Mar 2020 16:19:56 +0200 Subject: [PATCH 073/137] e164 --- notifiers/providers/twilio.py | 123 ++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/notifiers/providers/twilio.py b/notifiers/providers/twilio.py index 42a626d7..16a45e9b 100644 --- a/notifiers/providers/twilio.py +++ b/notifiers/providers/twilio.py @@ -1,8 +1,131 @@ +import re +from typing import List +from typing import Union + +from pydantic import condecimal +from pydantic import conint +from pydantic import constr +from pydantic import Field +from pydantic import HttpUrl +from pydantic import root_validator + from ..models.provider import Provider +from ..models.provider import SchemaModel from ..models.response import Response from ..utils import requests from ..utils.helpers import snake_to_camel_case +E164_re = re.compile(r"^\+?[1-9]\d{1,14}$") + + +class E164(str): + @classmethod + def __get_validators__(cls): + yield cls.validate + + @classmethod + def validate(cls, v): + if not E164_re.match(v): + raise ValueError("Value is not an E.164 formatted number") + return cls(v) + + +class TwilioSchema(SchemaModel): + """To send a new outgoing message, make an HTTP POST to this Messages list resource URI""" + + account_sid: str = Field( + ..., description="The SID of the Account that will create the resource" + ) + auth_token: str = Field(..., description="user authentication token") + to: Union[E164, str] = Field( + ..., + description="The destination phone number in E.164 format for SMS/MMS or Channel user address for other" + " 3rd-party channels", + ) + status_callback: HttpUrl = Field( + None, + description="The URL we should call using the status_callback_method to send status information to your " + "application. If specified, we POST these message status changes to the URL: queued, failed," + " sent, delivered, or undelivered. Twilio will POST its standard request parameters as well as " + "some additional parameters including MessageSid, MessageStatus, and ErrorCode. " + "If you include this parameter with the messaging_service_sid, we use this URL instead of the " + "Status Callback URL of the Messaging Service", + ) + application_sid: str = Field( + None, + description="The SID of the application that should receive message status. We POST a message_sid parameter " + "and a message_status parameter with a value of sent or failed to the application's " + "message_status_callback. If a status_callback parameter is also passed, " + "it will be ignored and the application's message_status_callback parameter will be used", + ) + max_price: condecimal(decimal_places=4) = Field( + None, + description="The maximum total price in US dollars that you will pay for the message to be delivered. " + "Can be a decimal value that has up to 4 decimal places. All messages are queued for delivery and " + "the message cost is checked before the message is sent. If the cost exceeds max_price, " + "the message will fail and a status of Failed is sent to the status callback. " + "If max_price is not set, the message cost is not checked", + ) + provide_feedback: bool = Field( + None, + description="Whether to confirm delivery of the message. " + "Set this value to true if you are sending messages that have a trackable user action and you " + "intend to confirm delivery of the message using the Message Feedback API", + ) + validity_period: conint(ge=1, le=14400) = Field( + None, + description="How long in seconds the message can remain in our outgoing message queue. " + "After this period elapses, the message fails and we call your status callback. " + "Can be between 1 and the default value of 14,400 seconds. After a message has been accepted by a " + "carrier, however, we cannot guarantee that the message will not be queued after this period. " + "We recommend that this value be at least 5 seconds", + ) + smart_encoded: bool = Field( + None, + description="Whether to detect Unicode characters that have a similar GSM-7 character and replace them", + ) + persistent_action: List[str] = Field( + None, description="Rich actions for Channels Messages" + ) + from_: E164 = Field( + None, + description="A Twilio phone number in E.164 format, an alphanumeric sender ID, or a Channel Endpoint address " + "that is enabled for the type of message you want to send. Phone numbers or short codes purchased " + "from Twilio also work here. You cannot, for example, spoof messages from a private cell phone " + "number. If you are using messaging_service_sid, this parameter must be empty", + ) + messaging_service_sid: str = Field( + None, + description="The SID of the Messaging Service you want to associate with the Message. " + "Set this parameter to use the Messaging Service Settings and Copilot Features you have " + "configured and leave the from parameter empty. When only this parameter is set, " + "Twilio will use your enabled Copilot Features to select the from phone number for delivery", + ) + message: constr(min_length=1, max_length=1600) = Field( + None, description="The text of the message you want to send", alias="body" + ) + media_url: SchemaModel.one_or_more_of(HttpUrl) = Field( + None, + description="The URL of the media to send with the message. The media can be of type gif, png, and jpeg and " + "will be formatted correctly on the recipient's device. The media size limit is 5MB for " + "supported file types (JPEG, PNG, GIF) and 500KB for other types of accepted media. " + "You can send images in an SMS message in only the US and Canada", + max_items=10, + ) + + @root_validator + def check_values(cls, values): + if not any(value in values for value in ("message", "media_url")): + raise ValueError("Either 'message' or 'media_url' are required") + + from_fields = [values.get(v) for v in ("from_", "messaging_service_sid")] + if not any(from_fields) or all(from_fields): + raise ValueError( + "Only one of 'from_' or 'messaging_service_sid' are allowed" + ) + + return values + class Twilio(Provider): """Send an SMS via a Twilio number""" From d03a580e5fa0800dde4d093311022d1f1c29b6c4 Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 8 Mar 2020 16:24:34 +0200 Subject: [PATCH 074/137] converted twilio --- notifiers/providers/twilio.py | 110 ++++------------------------------ 1 file changed, 11 insertions(+), 99 deletions(-) diff --git a/notifiers/providers/twilio.py b/notifiers/providers/twilio.py index 16a45e9b..2ebeaa8b 100644 --- a/notifiers/providers/twilio.py +++ b/notifiers/providers/twilio.py @@ -113,6 +113,9 @@ class TwilioSchema(SchemaModel): max_items=10, ) + class Config: + alias_generator = snake_to_camel_case + @root_validator def check_values(cls, values): if not any(value in values for value in ("message", "media_url")): @@ -135,105 +138,14 @@ class Twilio(Provider): site_url = "https://www.twilio.com/" path_to_errors = ("message",) - _required = { - "allOf": [ - { - "anyOf": [ - {"anyOf": [{"required": ["from"]}, {"required": ["from_"]}]}, - {"required": ["messaging_service_id"]}, - ], - "error_anyOf": "Either 'from' or 'messaging_service_id' are required", - }, - { - "anyOf": [{"required": ["message"]}, {"required": ["media_url"]}], - "error_anyOf": "Either 'message' or 'media_url' are required", - }, - {"required": ["to", "account_sid", "auth_token"]}, - ] - } - - _schema = { - "type": "object", - "properties": { - "message": { - "type": "string", - "title": "The text body of the message. Up to 1,600 characters long.", - "maxLength": 1_600, - }, - "account_sid": { - "type": "string", - "title": "The unique id of the Account that sent this message.", - }, - "auth_token": {"type": "string", "title": "The user's auth token"}, - "to": { - "type": "string", - "format": "e164", - "title": "The recipient of the message, in E.164 format", - }, - "from": { - "type": "string", - "title": "Twilio phone number or the alphanumeric sender ID used", - }, - "from_": { - "type": "string", - "title": "Twilio phone number or the alphanumeric sender ID used", - "duplicate": True, - }, - "messaging_service_id": { - "type": "string", - "title": "The unique id of the Messaging Service used with the message", - }, - "media_url": { - "type": "string", - "format": "uri", - "title": "The URL of the media you wish to send out with the message", - }, - "status_callback": { - "type": "string", - "format": "uri", - "title": "A URL where Twilio will POST each time your message status changes", - }, - "application_sid": { - "type": "string", - "title": "Twilio will POST MessageSid as well as MessageStatus=sent or MessageStatus=failed to the URL " - "in the MessageStatusCallback property of this Application", - }, - "max_price": { - "type": "number", - "title": "The total maximum price up to the fourth decimal (0.0001) in US dollars acceptable for " - "the message to be delivered", - }, - "provide_feedback": { - "type": "boolean", - "title": "Set this value to true if you are sending messages that have a trackable user action and " - "you intend to confirm delivery of the message using the Message Feedback API", - }, - "validity_period": { - "type": "integer", - "title": "The number of seconds that the message can remain in a Twilio queue", - "minimum": 1, - "maximum": 14_400, - }, - }, - } - - def _prepare_data(self, data: dict) -> dict: - if data.get("message"): - data["body"] = data.pop("message") - new_data = { - "auth_token": data.pop("auth_token"), - "account_sid": data.pop("account_sid"), - } - for key in data: - camel_case_key = snake_to_camel_case(key) - new_data[camel_case_key] = data[key] - return new_data - - def _send_notification(self, data: dict) -> Response: - account_sid = data.pop("account_sid") + schema_model = TwilioSchema + + def _send_notification(self, data: TwilioSchema) -> Response: + account_sid = data.account_sid url = self.base_url.format(account_sid) - auth = (account_sid, data.pop("auth_token")) + auth = account_sid, data.auth_token + payload = data.to_dict(exclude={"account_sid", "auth_token"}) response, errors = requests.post( - url, data=data, auth=auth, path_to_errors=self.path_to_errors + url, data=payload, auth=auth, path_to_errors=self.path_to_errors ) - return self.create_response(data, response, errors) + return self.create_response(payload, response, errors) From 567e20b0c0056e85284dca19fc3a056b2b5ca39e Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 8 Mar 2020 17:59:57 +0200 Subject: [PATCH 075/137] converted zulip --- notifiers/models/provider.py | 2 + notifiers/providers/zulip.py | 153 ++++++++++++++++++----------------- 2 files changed, 83 insertions(+), 72 deletions(-) diff --git a/notifiers/models/provider.py b/notifiers/models/provider.py index 2ee13c5b..d729708d 100644 --- a/notifiers/models/provider.py +++ b/notifiers/models/provider.py @@ -25,6 +25,7 @@ class SchemaModel(BaseModel): @staticmethod def to_list(value: Union[Any, List[Any]]) -> List[Any]: + # todo convert this to a custom type instead of a helper method """Helper method to make sure a return value is a list""" if not isinstance(value, list): return [value] @@ -32,6 +33,7 @@ def to_list(value: Union[Any, List[Any]]) -> List[Any]: @staticmethod def to_comma_separated(values: Union[Any, List[Any]]) -> str: + # todo convert this to a custom type instead of a helper method """Helper method that return a comma separates string from a value""" if not isinstance(values, list): values = [values] diff --git a/notifiers/providers/zulip.py b/notifiers/providers/zulip.py index 5264eb0e..1b625a63 100644 --- a/notifiers/providers/zulip.py +++ b/notifiers/providers/zulip.py @@ -1,86 +1,95 @@ -from ..exceptions import NotifierException +from enum import Enum +from typing import Union +from urllib.parse import urljoin + +from pydantic import constr +from pydantic import EmailStr +from pydantic import Field +from pydantic import HttpUrl +from pydantic import root_validator +from pydantic import ValidationError +from pydantic import validator + from ..models.provider import Provider +from ..models.provider import SchemaModel from ..models.response import Response from ..utils import requests +class ZulipUrl(str): + @classmethod + def __get_validators__(cls): + yield cls.validate + + @classmethod + def validate(cls, v): + try: + url = HttpUrl(v) + except ValidationError: + url = f"https://{v}.zulipchat.com" + return urljoin(url, "/api/v1/messages") + + +class MessageType(Enum): + private = "private" + stream = "stream" + + +class ZulipSchema(SchemaModel): + """Send a stream or a private message""" + + api_key: str = Field(..., description="User API Key") + url_or_domain: ZulipUrl = Field( + ..., + description="Either a full server URL or subdomain to be used with zulipchat.com", + ) + email: EmailStr = Field(..., description='"User email') + type: MessageType = Field( + MessageType.stream, + description="The type of message to be sent. private for a private message and stream for a stream message", + ) + message: constr(max_length=10000) = Field( + ..., description="The content of the message", alias="content" + ) + to: SchemaModel.one_or_more_of(Union[EmailStr, str]) = Field( + ..., + description="The destination stream, or a CSV/JSON-encoded list containing the usernames " + "(emails) of the recipients", + ) + topic: constr(max_length=60) = Field( + None, + description="The topic of the message. Only required if type is stream, ignored otherwise", + ) + + @validator("to", whole=True) + def csv(cls, v): + return SchemaModel.to_comma_separated(v) + + @root_validator + def root(cls, values): + if values["type"] is MessageType.stream and not values.get("topic"): + raise ValueError("'topic' is required when 'type' is 'stream'") + + return values + + class Config: + json_encoders = {MessageType: lambda v: v.value} + + class Zulip(Provider): """Send Zulip notifications""" name = "zulip" site_url = "https://zulipchat.com/api/" - api_endpoint = "/api/v1/messages" - base_url = "https://{domain}.zulipchat.com" path_to_errors = ("msg",) - __type = { - "type": "string", - "enum": ["stream", "private"], - "title": "Type of message to send", - } - _required = { - "allOf": [ - {"required": ["message", "email", "api_key", "to"]}, - { - "oneOf": [{"required": ["domain"]}, {"required": ["server"]}], - "error_oneOf": "Only one of 'domain' or 'server' is allowed", - }, - ] - } - - _schema = { - "type": "object", - "properties": { - "message": {"type": "string", "title": "Message content"}, - "email": {"type": "string", "format": "email", "title": "User email"}, - "api_key": {"type": "string", "title": "User API Key"}, - "type": __type, - "type_": __type, - "to": {"type": "string", "title": "Target of the message"}, - "subject": { - "type": "string", - "title": "Title of the stream message. Required when using stream.", - }, - "domain": {"type": "string", "minLength": 1, "title": "Zulip cloud domain"}, - "server": { - "type": "string", - "format": "uri", - "title": "Zulip server URL. Example: https://myzulip.server.com", - }, - }, - "additionalProperties": False, - } - - @property - def defaults(self) -> dict: - return {"type": "stream"} - - def _prepare_data(self, data: dict) -> dict: - base_url = ( - self.base_url.format(domain=data.pop("domain")) - if data.get("domain") - else data.pop("server") - ) - data["url"] = base_url + self.api_endpoint - data["content"] = data.pop("message") - # A workaround since `type` is a reserved word - if data.get("type_"): - data["type"] = data.pop("type_") - return data - - def _validate_data_dependencies(self, data: dict) -> dict: - if data["type"] == "stream" and not data.get("subject"): - raise NotifierException( - provider=self.name, - message="'subject' is required when 'type' is 'stream'", - data=data, - ) - return data - - def _send_notification(self, data: dict) -> Response: - url = data.pop("url") - auth = (data.pop("email"), data.pop("api_key")) + def _send_notification(self, data: ZulipSchema) -> Response: + auth = data.email, data.api_key + payload = data.to_dict(exclude={"email", "api_key", "url_or_domain"}) response, errors = requests.post( - url, data=data, auth=auth, path_to_errors=self.path_to_errors + data.url_or_domain, + data=payload, + auth=auth, + path_to_errors=self.path_to_errors, ) - return self.create_response(data, response, errors) + return self.create_response(payload, response, errors) From 9cb2b825f42cb3a20edac8376e45646f5fb1f760 Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 8 Mar 2020 20:53:04 +0200 Subject: [PATCH 076/137] moved values to exclude to its own class attribute --- notifiers/models/provider.py | 13 ++++++++++--- notifiers/providers/mailgun.py | 3 ++- notifiers/providers/statuspage.py | 3 ++- notifiers/providers/telegram.py | 4 +++- notifiers/providers/twilio.py | 4 +++- notifiers/providers/zulip.py | 4 +++- 6 files changed, 23 insertions(+), 8 deletions(-) diff --git a/notifiers/models/provider.py b/notifiers/models/provider.py index d729708d..7570ba92 100644 --- a/notifiers/models/provider.py +++ b/notifiers/models/provider.py @@ -4,6 +4,7 @@ from typing import Any from typing import List from typing import Optional +from typing import Tuple from typing import Union import requests @@ -23,6 +24,8 @@ class SchemaModel(BaseModel): """The base class for Schemas""" + _values_to_exclude: Tuple[str, ...] + @staticmethod def to_list(value: Union[Any, List[Any]]) -> List[Any]: # todo convert this to a custom type instead of a helper method @@ -59,7 +62,12 @@ def to_dict( :return: dict payload of the schema """ return json.loads( - self.json(exclude_none=exclude_none, by_alias=by_alias, **kwargs) + self.json( + exclude_none=exclude_none, + by_alias=by_alias, + exclude=set(self._values_to_exclude), + **kwargs, + ) ) class Config: @@ -158,9 +166,8 @@ def __getattr__(self, item): raise AttributeError(f"{self} does not have a property {item}") @property - @abstractmethod def base_url(self): - pass + return @property @abstractmethod diff --git a/notifiers/providers/mailgun.py b/notifiers/providers/mailgun.py index 14b1a1a7..09911e65 100644 --- a/notifiers/providers/mailgun.py +++ b/notifiers/providers/mailgun.py @@ -153,6 +153,7 @@ class MailGunSchema(SchemaModel): "dictionary with variables that can be referenced in the message body.", alias="recipient-variables", ) + _values_to_exclude = "domain", "api_key" @root_validator() def headers_and_data(cls, values): @@ -221,7 +222,7 @@ def _send_notification(self, data: MailGunSchema) -> Response: files += requests.file_list_for_request(data.attachment, "attachment") if data.inline: files += requests.file_list_for_request(data.inline, "inline") - payload = data.to_dict(exclude={"domain", "api_key"}) + payload = data.to_dict() response, errors = requests.post( url=url, data=payload, diff --git a/notifiers/providers/statuspage.py b/notifiers/providers/statuspage.py index c83580ee..385d7bf9 100644 --- a/notifiers/providers/statuspage.py +++ b/notifiers/providers/statuspage.py @@ -139,6 +139,7 @@ class StatuspageSchema(StatuspageBaseSchema): description="Same as 'scheduled_auto_transition_in_progress'. Controls whether the incident is " "scheduled to automatically change to in progress", ) + _values_to_exclude = "page_id", "api_key" @root_validator def values_dependencies(cls, values): @@ -226,7 +227,7 @@ class Statuspage(StatuspageMixin, Provider): def _send_notification(self, data: StatuspageSchema) -> Response: url = urljoin(self.base_url.format(page_id=data.page_id), self.incidents_url) headers = self.request_headers(data.api_key) - data_dict = data.to_dict(exclude={"page_id", "api_key"}) + data_dict = data.to_dict() payload = {"incident": data_dict} response, errors = requests.post( url, json=payload, headers=headers, path_to_errors=self.path_to_errors diff --git a/notifiers/providers/telegram.py b/notifiers/providers/telegram.py index a5409642..2b254184 100644 --- a/notifiers/providers/telegram.py +++ b/notifiers/providers/telegram.py @@ -254,6 +254,8 @@ class TelegramSchema(TelegramBaseSchema): " custom reply keyboard, instructions to remove reply keyboard or to force a reply from the user", ) + _values_to_exclude = ("token",) + class Config: json_encoders = {ParseMode: lambda v: v.value} @@ -295,7 +297,7 @@ class Telegram(TelegramMixin, Provider): def _send_notification(self, data: TelegramSchema) -> Response: url = urljoin(self.base_url.format(token=data.token), "sendMessage") - payload = data.to_dict(exclude={"token"}) + payload = data.to_dict() response, errors = requests.post( url, json=payload, path_to_errors=self.path_to_errors ) diff --git a/notifiers/providers/twilio.py b/notifiers/providers/twilio.py index 2ebeaa8b..08c77df3 100644 --- a/notifiers/providers/twilio.py +++ b/notifiers/providers/twilio.py @@ -116,6 +116,8 @@ class TwilioSchema(SchemaModel): class Config: alias_generator = snake_to_camel_case + _values_to_exclude = "account_sid", "auth_token" + @root_validator def check_values(cls, values): if not any(value in values for value in ("message", "media_url")): @@ -144,7 +146,7 @@ def _send_notification(self, data: TwilioSchema) -> Response: account_sid = data.account_sid url = self.base_url.format(account_sid) auth = account_sid, data.auth_token - payload = data.to_dict(exclude={"account_sid", "auth_token"}) + payload = data.to_dict() response, errors = requests.post( url, data=payload, auth=auth, path_to_errors=self.path_to_errors ) diff --git a/notifiers/providers/zulip.py b/notifiers/providers/zulip.py index 1b625a63..d25240bf 100644 --- a/notifiers/providers/zulip.py +++ b/notifiers/providers/zulip.py @@ -65,6 +65,8 @@ class ZulipSchema(SchemaModel): def csv(cls, v): return SchemaModel.to_comma_separated(v) + _values_to_exclude = "email", "api_key", "url_or_domain" + @root_validator def root(cls, values): if values["type"] is MessageType.stream and not values.get("topic"): @@ -85,7 +87,7 @@ class Zulip(Provider): def _send_notification(self, data: ZulipSchema) -> Response: auth = data.email, data.api_key - payload = data.to_dict(exclude={"email", "api_key", "url_or_domain"}) + payload = data.to_dict() response, errors = requests.post( data.url_or_domain, data=payload, From c6d06f75e2aba36a5ee53b6e787b6f7631120a45 Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 8 Mar 2020 21:38:18 +0200 Subject: [PATCH 077/137] name tweaks --- notifiers/models/provider.py | 12 ++++++------ notifiers/providers/email.py | 10 +++++----- notifiers/providers/gitter.py | 4 ++-- notifiers/providers/join.py | 8 ++++---- notifiers/providers/mailgun.py | 16 ++++++++-------- notifiers/providers/pagerduty.py | 16 ++++++++++------ notifiers/providers/popcornnotify.py | 6 +++--- notifiers/providers/pushbullet.py | 4 ++-- notifiers/providers/pushover.py | 4 ++-- notifiers/providers/simplepush.py | 4 ++-- notifiers/providers/slack/blocks.py | 4 ++-- notifiers/providers/slack/composition.py | 10 +++++----- notifiers/providers/slack/elements.py | 4 ++-- notifiers/providers/slack/main.py | 8 ++++---- notifiers/providers/statuspage.py | 4 ++-- notifiers/providers/telegram.py | 20 ++++++++++---------- notifiers/providers/twilio.py | 6 +++--- notifiers/providers/zulip.py | 8 ++++---- 18 files changed, 76 insertions(+), 72 deletions(-) diff --git a/notifiers/models/provider.py b/notifiers/models/provider.py index 7570ba92..6f400c2f 100644 --- a/notifiers/models/provider.py +++ b/notifiers/models/provider.py @@ -21,7 +21,7 @@ DEFAULT_ENVIRON_PREFIX = "NOTIFIERS_" -class SchemaModel(BaseModel): +class ResourceSchema(BaseModel): """The base class for Schemas""" _values_to_exclude: Tuple[str, ...] @@ -78,7 +78,7 @@ class Config: class SchemaResource(ABC): """Base class that represent an object schema and its utility methods""" - schema_model: SchemaModel + schema_model: ResourceSchema @property @abstractmethod @@ -100,7 +100,7 @@ def required(self) -> Optional[List[str]]: """Resource's required arguments. Note that additional validation may not be represented here""" return self.schema.get("required") - def validate_data(self, data: dict) -> SchemaModel: + def validate_data(self, data: dict) -> ResourceSchema: try: return self.schema_model.parse_obj(data) except ValidationError as e: @@ -136,7 +136,7 @@ def _get_environs(self, prefix: str) -> dict: """ return dict_from_environs(prefix, self.name, list(self.arguments.keys())) - def _process_data(self, data: dict) -> SchemaModel: + def _process_data(self, data: dict) -> ResourceSchema: """ The main method that process all resources data. Validates schema, gets environs, validates data, prepares it via provider requirements, merges defaults and check for data dependencies @@ -187,7 +187,7 @@ def resources(self) -> list: return list(self._resources.keys()) @abstractmethod - def _send_notification(self, data: SchemaModel) -> Response: + def _send_notification(self, data: ResourceSchema) -> Response: """ The core method to trigger the provider notification. Must be overridden. @@ -222,7 +222,7 @@ def resource_name(self): pass @abstractmethod - def _get_resource(self, data: dict): + def _get_resource(self, data: ResourceSchema): pass def __call__(self, **kwargs): diff --git a/notifiers/providers/email.py b/notifiers/providers/email.py index f72de64c..d75663ee 100644 --- a/notifiers/providers/email.py +++ b/notifiers/providers/email.py @@ -18,27 +18,27 @@ from pydantic import validator from ..models.provider import Provider -from ..models.provider import SchemaModel +from ..models.provider import ResourceSchema from ..models.response import Response -class SMTPSchema(SchemaModel): +class SMTPSchema(ResourceSchema): """SMTP email schema""" message: str = Field(..., description="The content of the email message") subject: str = Field( "New email from 'notifiers'!", description="The subject of the email message" ) - to: SchemaModel.one_or_more_of(EmailStr) = Field( + to: ResourceSchema.one_or_more_of(EmailStr) = Field( ..., description="One or more email addresses to use" ) - from_: SchemaModel.one_or_more_of(EmailStr) = Field( + from_: ResourceSchema.one_or_more_of(EmailStr) = Field( f"{getpass.getuser()}@{socket.getfqdn()}", description="One or more FROM addresses to use", alias="from", title="from", ) - attachments: SchemaModel.one_or_more_of(FilePath) = Field( + attachments: ResourceSchema.one_or_more_of(FilePath) = Field( [], description="One or more attachments to use in the email" ) host: str = Field("localhost", description="The host of the SMTP server") diff --git a/notifiers/providers/gitter.py b/notifiers/providers/gitter.py index f408afa8..11aadca6 100644 --- a/notifiers/providers/gitter.py +++ b/notifiers/providers/gitter.py @@ -5,12 +5,12 @@ from ..exceptions import ResourceError from ..models.provider import Provider from ..models.provider import ProviderResource -from ..models.provider import SchemaModel +from ..models.provider import ResourceSchema from ..models.response import Response from ..utils import requests -class GitterSchemaBase(SchemaModel): +class GitterSchemaBase(ResourceSchema): token: str = Field(..., description="Access token") @property diff --git a/notifiers/providers/join.py b/notifiers/providers/join.py index 94202d40..4e7e5044 100644 --- a/notifiers/providers/join.py +++ b/notifiers/providers/join.py @@ -13,7 +13,7 @@ from ..exceptions import ResourceError from ..models.provider import Provider from ..models.provider import ProviderResource -from ..models.provider import SchemaModel +from ..models.provider import ResourceSchema from ..models.response import Response @@ -26,7 +26,7 @@ class JoinGroup(Enum): pc = "group.pc" -class JoinBaseSchema(SchemaModel): +class JoinBaseSchema(ResourceSchema): api_key: str = Field(..., description="User API key", alias="apikey") class Config: @@ -46,12 +46,12 @@ class JoinSchema(JoinBaseSchema): description="The device ID or group ID of the device you want to send the message to", alias="deviceId", ) - device_ids: SchemaModel.one_or_more_of(str) = Field( + device_ids: ResourceSchema.one_or_more_of(str) = Field( None, description="A comma separated list of device IDs you want to send the push to", alias="deviceIds", ) - device_names: SchemaModel.one_or_more_of(str) = Field( + device_names: ResourceSchema.one_or_more_of(str) = Field( None, description="A comma separated list of device names you want to send the push to", alias="deviceNames", diff --git a/notifiers/providers/mailgun.py b/notifiers/providers/mailgun.py index 09911e65..5e830d14 100644 --- a/notifiers/providers/mailgun.py +++ b/notifiers/providers/mailgun.py @@ -16,7 +16,7 @@ from typing_extensions import Literal from ..models.provider import Provider -from ..models.provider import SchemaModel +from ..models.provider import ResourceSchema from ..models.response import Response from ..utils import requests @@ -36,7 +36,7 @@ def validate(cls, v): raise ValueError("Value must be valid ascii") -class MailGunSchema(SchemaModel): +class MailGunSchema(ResourceSchema): """Send a mailgun email""" api_key: str = Field(..., description="User's API key") @@ -44,13 +44,13 @@ class MailGunSchema(SchemaModel): from_: NameEmail = Field( ..., description="Email address for 'From' header", alias="from" ) - to: SchemaModel.one_or_more_of(Union[EmailStr, NameEmail]) = Field( + to: ResourceSchema.one_or_more_of(Union[EmailStr, NameEmail]) = Field( ..., description="Email address of the recipient(s)" ) - cc: SchemaModel.one_or_more_of(NameEmail) = Field( + cc: ResourceSchema.one_or_more_of(NameEmail) = Field( None, description="Email address of the CC recipient(s)" ) - bcc: SchemaModel.one_or_more_of(NameEmail) = Field( + bcc: ResourceSchema.one_or_more_of(NameEmail) = Field( None, description="Email address of the BCC recipient(s)" ) subject: str = Field(None, description="Message subject") @@ -63,10 +63,10 @@ class MailGunSchema(SchemaModel): description="AMP part of the message. Please follow google guidelines to compose and send AMP emails.", alias="amp-html", ) - attachment: SchemaModel.one_or_more_of(FilePath) = Field( + attachment: ResourceSchema.one_or_more_of(FilePath) = Field( None, description="File attachment(s)" ) - inline: SchemaModel.one_or_more_of(FilePath) = Field( + inline: ResourceSchema.one_or_more_of(FilePath) = Field( None, description="Attachment with inline disposition. Can be used to send inline images", ) @@ -140,7 +140,7 @@ class MailGunSchema(SchemaModel): alias="o:skip-verification", ) - headers: SchemaModel.one_or_more_of(Dict[str, str]) = Field( + headers: ResourceSchema.one_or_more_of(Dict[str, str]) = Field( None, description="Add arbitrary value(s) to append a custom MIME header to the message", ) diff --git a/notifiers/providers/pagerduty.py b/notifiers/providers/pagerduty.py index 56cd323b..eddf080c 100644 --- a/notifiers/providers/pagerduty.py +++ b/notifiers/providers/pagerduty.py @@ -8,12 +8,16 @@ from pydantic import validator from ..models.provider import Provider -from ..models.provider import SchemaModel +from ..models.provider import ResourceSchema from ..models.response import Response from ..utils import requests -class PagerDutyLink(SchemaModel): +class HttpsUrl(HttpUrl): + allowed_schemes = ("https",) + + +class PagerDutyLink(ResourceSchema): href: HttpUrl = Field(..., description="URL of the link to be attached.") text: str = Field( ..., @@ -21,8 +25,8 @@ class PagerDutyLink(SchemaModel): ) -class PagerDutyImage(SchemaModel): - src: HttpUrl = Field( +class PagerDutyImage(ResourceSchema): + src: HttpsUrl = Field( ..., description="The source of the image being attached to the incident. This image must be served via HTTPS.", ) @@ -45,7 +49,7 @@ class PagerDutyEventAction(Enum): resolve = "resolve" -class PagerDutyPayload(SchemaModel): +class PagerDutyPayload(ResourceSchema): message: constr(max_length=1024) = Field( ..., description="A brief text summary of the event," @@ -90,7 +94,7 @@ class Config: allow_population_by_field_name = True -class PagerDutySchema(SchemaModel): +class PagerDutySchema(ResourceSchema): routing_key: constr(min_length=32, max_length=32) = Field( ..., description="This is the 32 character Integration Key for an integration on a service or on a global ruleset", diff --git a/notifiers/providers/popcornnotify.py b/notifiers/providers/popcornnotify.py index af87b534..01bb05ee 100644 --- a/notifiers/providers/popcornnotify.py +++ b/notifiers/providers/popcornnotify.py @@ -2,19 +2,19 @@ from pydantic import validator from ..models.provider import Provider -from ..models.provider import SchemaModel +from ..models.provider import ResourceSchema from ..models.response import Response from ..utils import requests -class PopcornNotifySchema(SchemaModel): +class PopcornNotifySchema(ResourceSchema): message: str = Field(..., description="The message to send") api_key: str = Field(..., description="The API key") subject: str = Field( None, description="The subject of the email. It will not be included in text messages", ) - recipients: SchemaModel.one_or_more_of(str) = Field( + recipients: ResourceSchema.one_or_more_of(str) = Field( ..., description="The recipient email address or phone number.Or an array of email addresses and phone numbers", ) diff --git a/notifiers/providers/pushbullet.py b/notifiers/providers/pushbullet.py index f541c2ab..64b04fa9 100644 --- a/notifiers/providers/pushbullet.py +++ b/notifiers/providers/pushbullet.py @@ -11,7 +11,7 @@ from ..exceptions import ResourceError from ..models.provider import Provider from ..models.provider import ProviderResource -from ..models.provider import SchemaModel +from ..models.provider import ResourceSchema from ..models.response import Response from ..utils import requests @@ -22,7 +22,7 @@ class PushbulletType(Enum): link = "link" -class PushbulletBaseSchema(SchemaModel): +class PushbulletBaseSchema(ResourceSchema): token: str = Field(..., description="API access token") diff --git a/notifiers/providers/pushover.py b/notifiers/providers/pushover.py index 06838616..7e431482 100644 --- a/notifiers/providers/pushover.py +++ b/notifiers/providers/pushover.py @@ -12,7 +12,7 @@ from ..exceptions import ResourceError from ..models.provider import Provider from ..models.provider import ProviderResource -from ..models.provider import SchemaModel +from ..models.provider import ResourceSchema from ..models.response import Response from ..utils import requests @@ -42,7 +42,7 @@ class PushoverSound(Enum): none = None -class PushoverBaseSchema(SchemaModel): +class PushoverBaseSchema(ResourceSchema): token: str = Field(..., description="Your application's API token ") diff --git a/notifiers/providers/simplepush.py b/notifiers/providers/simplepush.py index 57ec16a0..c408775f 100644 --- a/notifiers/providers/simplepush.py +++ b/notifiers/providers/simplepush.py @@ -1,12 +1,12 @@ from pydantic import Field from ..models.provider import Provider -from ..models.provider import SchemaModel +from ..models.provider import ResourceSchema from ..models.response import Response from ..utils import requests -class SimplePushSchema(SchemaModel): +class SimplePushSchema(ResourceSchema): key: str = Field(..., description="Your user key") message: str = Field(..., description="Your message", alias="msg") title: str = Field(None, description="Message title") diff --git a/notifiers/providers/slack/blocks.py b/notifiers/providers/slack/blocks.py index fc106a82..0784b557 100644 --- a/notifiers/providers/slack/blocks.py +++ b/notifiers/providers/slack/blocks.py @@ -23,7 +23,7 @@ from .elements import SelectChannelsElement from .elements import SelectUsersElement from .elements import StaticSelectElement -from notifiers.models.provider import SchemaModel +from notifiers.models.provider import ResourceSchema from notifiers.providers.slack.composition import _text_object_factory from notifiers.providers.slack.composition import Text from notifiers.providers.slack.composition import TextType @@ -70,7 +70,7 @@ class BlockType(Enum): file = "file" -class BaseBlock(SchemaModel): +class BaseBlock(ResourceSchema): block_id: constr(max_length=255) = Field( None, description="A string acting as a unique identifier for a block. " diff --git a/notifiers/providers/slack/composition.py b/notifiers/providers/slack/composition.py index 5c237415..84c15042 100644 --- a/notifiers/providers/slack/composition.py +++ b/notifiers/providers/slack/composition.py @@ -9,7 +9,7 @@ from pydantic import root_validator from typing_extensions import Literal -from notifiers.models.provider import SchemaModel +from notifiers.models.provider import ResourceSchema class TextType(Enum): @@ -17,7 +17,7 @@ class TextType(Enum): markdown = "mrkdwn" -class Text(SchemaModel): +class Text(ResourceSchema): """An object containing some text, formatted either as plain_text or using mrkdwn, our proprietary textual markup that's just different enough from Markdown to frustrate you""" @@ -66,7 +66,7 @@ def _text_object_factory( ) -class Option(SchemaModel): +class Option(ResourceSchema): """An object that represents a single selectable item in a select menu, multi-select menu, radio button group, or overflow menu.""" @@ -97,7 +97,7 @@ class Option(SchemaModel): ) -class OptionGroup(SchemaModel): +class OptionGroup(ResourceSchema): """Provides a way to group options in a select menu or multi-select menu""" label: _text_object_factory( @@ -113,7 +113,7 @@ class OptionGroup(SchemaModel): ) -class ConfirmationDialog(SchemaModel): +class ConfirmationDialog(ResourceSchema): """An object that defines a dialog that provides a confirmation step to any interactive element. This dialog will ask the user to confirm their action by offering a confirm and deny buttons.""" diff --git a/notifiers/providers/slack/elements.py b/notifiers/providers/slack/elements.py index bc0964b7..3645fbdf 100644 --- a/notifiers/providers/slack/elements.py +++ b/notifiers/providers/slack/elements.py @@ -8,7 +8,7 @@ from pydantic import PositiveInt from pydantic import root_validator -from notifiers.models.provider import SchemaModel +from notifiers.models.provider import ResourceSchema from notifiers.providers.slack.composition import _text_object_factory from notifiers.providers.slack.composition import ConfirmationDialog from notifiers.providers.slack.composition import Option @@ -38,7 +38,7 @@ class ElementType(Enum): channels_select = "channels_select" -class _BaseElement(SchemaModel): +class _BaseElement(ResourceSchema): type: ElementType = Field(..., description="The type of element") action_id: constr(max_length=255) = Field( None, diff --git a/notifiers/providers/slack/main.py b/notifiers/providers/slack/main.py index 72b636f3..f6e5c4e9 100644 --- a/notifiers/providers/slack/main.py +++ b/notifiers/providers/slack/main.py @@ -10,14 +10,14 @@ from pydantic.color import Color as ColorType from notifiers.models.provider import Provider -from notifiers.models.provider import SchemaModel +from notifiers.models.provider import ResourceSchema from notifiers.models.response import Response from notifiers.providers.slack.blocks import Blocks from notifiers.providers.slack.composition import Color from notifiers.utils import requests -class FieldObject(SchemaModel): +class FieldObject(ResourceSchema): title: str = Field( None, description="Shown as a bold heading displayed in the field object." @@ -35,7 +35,7 @@ class FieldObject(SchemaModel): ) -class AttachmentSchema(SchemaModel): +class AttachmentSchema(ResourceSchema): """Secondary content can be attached to messages to include lower priority content - content that doesn't necessarily need to be seen to appreciate the intent of the message, but perhaps adds further context or additional information.""" @@ -150,7 +150,7 @@ def check_values(cls, values): return values -class SlackSchema(SchemaModel): +class SlackSchema(ResourceSchema): """Slack's webhook schema""" webhook_url: HttpUrl = Field( diff --git a/notifiers/providers/statuspage.py b/notifiers/providers/statuspage.py index 385d7bf9..e1a8d2bb 100644 --- a/notifiers/providers/statuspage.py +++ b/notifiers/providers/statuspage.py @@ -11,7 +11,7 @@ from ..exceptions import ResourceError from ..models.provider import Provider from ..models.provider import ProviderResource -from ..models.provider import SchemaModel +from ..models.provider import ResourceSchema from ..models.response import Response from ..utils import requests @@ -46,7 +46,7 @@ class ComponentStatus(Enum): empty = "" -class StatuspageBaseSchema(SchemaModel): +class StatuspageBaseSchema(ResourceSchema): api_key: str = Field(..., description="Authentication token") page_id: str = Field(..., description="Paged ID") diff --git a/notifiers/providers/telegram.py b/notifiers/providers/telegram.py index 2b254184..9cb056ce 100644 --- a/notifiers/providers/telegram.py +++ b/notifiers/providers/telegram.py @@ -10,7 +10,7 @@ from ..exceptions import ResourceError from ..models.provider import Provider from ..models.provider import ProviderResource -from ..models.provider import SchemaModel +from ..models.provider import ResourceSchema from ..models.response import Response from ..utils import requests @@ -19,7 +19,7 @@ class TelegramURL(HttpUrl): allowed_schemes = "http", "https", "tg" -class LoginUrl(SchemaModel): +class LoginUrl(ResourceSchema): """This object represents a parameter of the inline keyboard button used to automatically authorize a user. Serves as a great replacement for the Telegram Login Widget when the user is coming from Telegram. All the user needs to do is tap/click a button and confirm that they want to log in""" @@ -46,7 +46,7 @@ class LoginUrl(SchemaModel): ) -class InlineKeyboardButton(SchemaModel): +class InlineKeyboardButton(ResourceSchema): """This object represents one button of an inline keyboard. You must use exactly one of the optional fields""" text: str = Field(..., description="Label text on the button") @@ -89,7 +89,7 @@ def only_one_optional(cls, values): return values -class KeyboardButtonPollType(SchemaModel): +class KeyboardButtonPollType(ResourceSchema): type: str = Field( None, description="If quiz is passed, the user will be allowed to create only polls in the quiz mode." @@ -98,7 +98,7 @@ class KeyboardButtonPollType(SchemaModel): ) -class KeyboardButton(SchemaModel): +class KeyboardButton(ResourceSchema): """This object represents one button of the reply keyboard. For simple text buttons String can be used instead of this object to specify text of the button. Optional fields request_contact, request_location, and request_poll are mutually exclusive""" @@ -125,7 +125,7 @@ class KeyboardButton(SchemaModel): ) -class InlineKeyboardMarkup(SchemaModel): +class InlineKeyboardMarkup(ResourceSchema): """This object represents an inline keyboard that appears right next to the message it belongs to""" inline_keyboard: List[List[InlineKeyboardButton]] = Field( @@ -134,7 +134,7 @@ class InlineKeyboardMarkup(SchemaModel): ) -class ReplyKeyboardMarkup(SchemaModel): +class ReplyKeyboardMarkup(ResourceSchema): """This object represents a custom keyboard with reply options (see Introduction to bots for details and examples)""" @@ -166,7 +166,7 @@ class ReplyKeyboardMarkup(SchemaModel): ) -class ReplyKeyboardRemove(SchemaModel): +class ReplyKeyboardRemove(ResourceSchema): """Upon receiving a message with this object, Telegram clients will remove the current custom keyboard and display the default letter-keyboard. By default, custom keyboards are displayed until a new keyboard is sent by a bot. An exception is made for one-time keyboards that are hidden immediately after the user presses a button @@ -189,7 +189,7 @@ class ReplyKeyboardRemove(SchemaModel): ) -class ForceReply(SchemaModel): +class ForceReply(ResourceSchema): """Upon receiving a message with this object, Telegram clients will display a reply interface to the user (act as if the user has selected the bot‘s message and tapped ’Reply'). This can be extremely useful if you want to create user-friendly step-by-step interfaces without having @@ -214,7 +214,7 @@ class ParseMode(Enum): markdown_v2 = "MarkdownV2" -class TelegramBaseSchema(SchemaModel): +class TelegramBaseSchema(ResourceSchema): token: str = Field(..., description="Bot token") diff --git a/notifiers/providers/twilio.py b/notifiers/providers/twilio.py index 08c77df3..e67013b7 100644 --- a/notifiers/providers/twilio.py +++ b/notifiers/providers/twilio.py @@ -10,7 +10,7 @@ from pydantic import root_validator from ..models.provider import Provider -from ..models.provider import SchemaModel +from ..models.provider import ResourceSchema from ..models.response import Response from ..utils import requests from ..utils.helpers import snake_to_camel_case @@ -30,7 +30,7 @@ def validate(cls, v): return cls(v) -class TwilioSchema(SchemaModel): +class TwilioSchema(ResourceSchema): """To send a new outgoing message, make an HTTP POST to this Messages list resource URI""" account_sid: str = Field( @@ -104,7 +104,7 @@ class TwilioSchema(SchemaModel): message: constr(min_length=1, max_length=1600) = Field( None, description="The text of the message you want to send", alias="body" ) - media_url: SchemaModel.one_or_more_of(HttpUrl) = Field( + media_url: ResourceSchema.one_or_more_of(HttpUrl) = Field( None, description="The URL of the media to send with the message. The media can be of type gif, png, and jpeg and " "will be formatted correctly on the recipient's device. The media size limit is 5MB for " diff --git a/notifiers/providers/zulip.py b/notifiers/providers/zulip.py index d25240bf..0efc016d 100644 --- a/notifiers/providers/zulip.py +++ b/notifiers/providers/zulip.py @@ -11,7 +11,7 @@ from pydantic import validator from ..models.provider import Provider -from ..models.provider import SchemaModel +from ..models.provider import ResourceSchema from ..models.response import Response from ..utils import requests @@ -35,7 +35,7 @@ class MessageType(Enum): stream = "stream" -class ZulipSchema(SchemaModel): +class ZulipSchema(ResourceSchema): """Send a stream or a private message""" api_key: str = Field(..., description="User API Key") @@ -51,7 +51,7 @@ class ZulipSchema(SchemaModel): message: constr(max_length=10000) = Field( ..., description="The content of the message", alias="content" ) - to: SchemaModel.one_or_more_of(Union[EmailStr, str]) = Field( + to: ResourceSchema.one_or_more_of(Union[EmailStr, str]) = Field( ..., description="The destination stream, or a CSV/JSON-encoded list containing the usernames " "(emails) of the recipients", @@ -63,7 +63,7 @@ class ZulipSchema(SchemaModel): @validator("to", whole=True) def csv(cls, v): - return SchemaModel.to_comma_separated(v) + return ResourceSchema.to_comma_separated(v) _values_to_exclude = "email", "api_key", "url_or_domain" From d5e2f08ed4db733dc4b760ad296d9e464ca24ee7 Mon Sep 17 00:00:00 2001 From: liiight Date: Sun, 8 Mar 2020 23:12:42 +0200 Subject: [PATCH 078/137] made str enums inherit from str --- notifiers/models/response.py | 2 +- notifiers/providers/join.py | 2 +- notifiers/providers/pagerduty.py | 4 ++-- notifiers/providers/pushbullet.py | 2 +- notifiers/providers/pushover.py | 2 +- notifiers/providers/slack/blocks.py | 2 +- notifiers/providers/slack/composition.py | 2 +- notifiers/providers/slack/elements.py | 4 ++-- notifiers/providers/statuspage.py | 25 ++++++++++++------------ notifiers/providers/telegram.py | 2 +- notifiers/providers/zulip.py | 2 +- 11 files changed, 24 insertions(+), 25 deletions(-) diff --git a/notifiers/models/response.py b/notifiers/models/response.py index 1997c594..46504ca2 100644 --- a/notifiers/models/response.py +++ b/notifiers/models/response.py @@ -5,7 +5,7 @@ from ..exceptions import NotificationError -class ResponseStatus(Enum): +class ResponseStatus(str, Enum): SUCCESS = "success" FAILURE = "failure" diff --git a/notifiers/providers/join.py b/notifiers/providers/join.py index 4e7e5044..065be34c 100644 --- a/notifiers/providers/join.py +++ b/notifiers/providers/join.py @@ -17,7 +17,7 @@ from ..models.response import Response -class JoinGroup(Enum): +class JoinGroup(str, Enum): all_ = "group.all" android = "group.android" windows_10 = "group.windows10" diff --git a/notifiers/providers/pagerduty.py b/notifiers/providers/pagerduty.py index eddf080c..75c1f94a 100644 --- a/notifiers/providers/pagerduty.py +++ b/notifiers/providers/pagerduty.py @@ -36,14 +36,14 @@ class PagerDutyImage(ResourceSchema): alt: str = Field(None, description="Optional alternative text for the image.") -class PagerDutyPayloadSeverity(Enum): +class PagerDutyPayloadSeverity(str, Enum): info = "info" warning = "warning" error = "error" critical = "critical" -class PagerDutyEventAction(Enum): +class PagerDutyEventAction(str, Enum): trigger = "trigger" acknowledge = "acknowledge" resolve = "resolve" diff --git a/notifiers/providers/pushbullet.py b/notifiers/providers/pushbullet.py index 64b04fa9..4a540682 100644 --- a/notifiers/providers/pushbullet.py +++ b/notifiers/providers/pushbullet.py @@ -16,7 +16,7 @@ from ..utils import requests -class PushbulletType(Enum): +class PushbulletType(str, Enum): note = "note" file = "file" link = "link" diff --git a/notifiers/providers/pushover.py b/notifiers/providers/pushover.py index 7e431482..953c8067 100644 --- a/notifiers/providers/pushover.py +++ b/notifiers/providers/pushover.py @@ -17,7 +17,7 @@ from ..utils import requests -class PushoverSound(Enum): +class PushoverSound(str, Enum): pushover = "pushover" bike = "bike" bugle = "bugle" diff --git a/notifiers/providers/slack/blocks.py b/notifiers/providers/slack/blocks.py index 0784b557..6389ba0f 100644 --- a/notifiers/providers/slack/blocks.py +++ b/notifiers/providers/slack/blocks.py @@ -61,7 +61,7 @@ ContextBlockElements = Union[ImageElement, Text] -class BlockType(Enum): +class BlockType(str, Enum): section = "section" divider = "divider" image = "image" diff --git a/notifiers/providers/slack/composition.py b/notifiers/providers/slack/composition.py index 84c15042..8cdf4c13 100644 --- a/notifiers/providers/slack/composition.py +++ b/notifiers/providers/slack/composition.py @@ -127,7 +127,7 @@ class ConfirmationDialog(ResourceSchema): ) -class Color(Enum): +class Color(str, Enum): good = "good" warning = "warning" danger = "danger" diff --git a/notifiers/providers/slack/elements.py b/notifiers/providers/slack/elements.py index 3645fbdf..4c495b4e 100644 --- a/notifiers/providers/slack/elements.py +++ b/notifiers/providers/slack/elements.py @@ -16,7 +16,7 @@ from notifiers.providers.slack.composition import TextType -class ElementType(Enum): +class ElementType(str, Enum): button = "button" checkboxes = "checkboxes" date_picker = "datepicker" @@ -51,7 +51,7 @@ class Config: json_encoders = {ElementType: lambda v: v.value} -class ButtonElementStyle(Enum): +class ButtonElementStyle(str, Enum): primary = "primary" danger = "danger" default = None diff --git a/notifiers/providers/statuspage.py b/notifiers/providers/statuspage.py index e1a8d2bb..2c890abc 100644 --- a/notifiers/providers/statuspage.py +++ b/notifiers/providers/statuspage.py @@ -16,7 +16,7 @@ from ..utils import requests -class Impact(Enum): +class Impact(str, Enum): critical = "critical" major = "major" minor = "minor" @@ -24,7 +24,7 @@ class Impact(Enum): none = "none" -class IncidentStatus(Enum): +class IncidentStatus(str, Enum): postmortem = "postmortem" investigating = "investigating" identified = "identified" @@ -37,7 +37,7 @@ class IncidentStatus(Enum): completed = "completed" -class ComponentStatus(Enum): +class ComponentStatus(str, Enum): operational = "operational" under_maintenance = "under_maintenance" degraded_performance = "degraded_performance" @@ -50,6 +50,10 @@ class StatuspageBaseSchema(ResourceSchema): api_key: str = Field(..., description="Authentication token") page_id: str = Field(..., description="Paged ID") + @property + def auth_headers(self) -> Dict[str, str]: + return {"Authorization": f"OAuth {self.api_key}"} + class StatuspageSchema(StatuspageBaseSchema): """Statuspage incident creation schema""" @@ -186,10 +190,6 @@ class StatuspageMixin: path_to_errors = ("error",) site_url = "https://statuspage.io" - @staticmethod - def request_headers(api_key: str) -> dict: - return {"Authorization": f"OAuth {api_key}"} - class StatuspageComponents(StatuspageMixin, ProviderResource): """Return a list of Statuspage components for the page ID""" @@ -200,9 +200,8 @@ class StatuspageComponents(StatuspageMixin, ProviderResource): def _get_resource(self, data: StatuspageBaseSchema) -> dict: url = urljoin(self.base_url.format(page_id=data.page_id), self.components_url) - headers = self.request_headers(data.api_key) response, errors = requests.get( - url, headers=headers, path_to_errors=self.path_to_errors + url, headers=data.auth_headers, path_to_errors=self.path_to_errors ) if errors: raise ResourceError( @@ -219,17 +218,17 @@ class Statuspage(StatuspageMixin, Provider): """Create Statuspage incidents""" incidents_url = "incidents" - _resources = {"components": StatuspageComponents()} - schema_model = StatuspageSchema def _send_notification(self, data: StatuspageSchema) -> Response: url = urljoin(self.base_url.format(page_id=data.page_id), self.incidents_url) - headers = self.request_headers(data.api_key) data_dict = data.to_dict() payload = {"incident": data_dict} response, errors = requests.post( - url, json=payload, headers=headers, path_to_errors=self.path_to_errors + url, + json=payload, + headers=data.auth_headers, + path_to_errors=self.path_to_errors, ) return self.create_response(data_dict, response, errors) diff --git a/notifiers/providers/telegram.py b/notifiers/providers/telegram.py index 9cb056ce..5f6ae3e5 100644 --- a/notifiers/providers/telegram.py +++ b/notifiers/providers/telegram.py @@ -208,7 +208,7 @@ class ForceReply(ResourceSchema): ) -class ParseMode(Enum): +class ParseMode(str, Enum): markdown = "Markdown" html = "HTML" markdown_v2 = "MarkdownV2" diff --git a/notifiers/providers/zulip.py b/notifiers/providers/zulip.py index 0efc016d..3095cc52 100644 --- a/notifiers/providers/zulip.py +++ b/notifiers/providers/zulip.py @@ -30,7 +30,7 @@ def validate(cls, v): return urljoin(url, "/api/v1/messages") -class MessageType(Enum): +class MessageType(str, Enum): private = "private" stream = "stream" From ec18e3eac6da614def1a318edc3b4ca55e21a6b1 Mon Sep 17 00:00:00 2001 From: liiight Date: Mon, 9 Mar 2020 00:26:55 +0200 Subject: [PATCH 079/137] started fixing tests --- notifiers/core.py | 1 - notifiers/exceptions.py | 17 ------ tests/conftest.py | 81 +++++++++------------------ tests/providers/test_popcornnotify.py | 4 +- tests/providers/test_statuspage.py | 4 +- tests/test_core.py | 6 -- 6 files changed, 30 insertions(+), 83 deletions(-) diff --git a/notifiers/core.py b/notifiers/core.py index a93033fd..7a99e95a 100644 --- a/notifiers/core.py +++ b/notifiers/core.py @@ -6,7 +6,6 @@ log = logging.getLogger("notifiers") -FAILURE_STATUS = "Failure" SUCCESS_STATUS = "Success" # Avoid premature import diff --git a/notifiers/exceptions.py b/notifiers/exceptions.py index 7feac84f..4bd27ce7 100644 --- a/notifiers/exceptions.py +++ b/notifiers/exceptions.py @@ -39,23 +39,6 @@ def __repr__(self): return f"" -class SchemaError(NotifierException): - """ - Raised on schema issues, relevant probably when creating or changing a provider schema - - :param schema_error: The schema error that was raised - :param args: Exception arguments - :param kwargs: Exception kwargs - """ - - def __init__(self, schema_error: str, *args, **kwargs): - kwargs["message"] = f"Schema error: {schema_error}" - super().__init__(*args, **kwargs) - - def __repr__(self): - return f"" - - class NotificationError(NotifierException): """ A notification error. Raised after an issue with the sent notification. diff --git a/tests/conftest.py b/tests/conftest.py index f7e8539c..56773a14 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,17 +6,18 @@ import pytest from click.testing import CliRunner +from pydantic import Field +from pydantic import validator from notifiers.core import get_notifier -from notifiers.core import SUCCESS_STATUS from notifiers.logging import NotificationHandler from notifiers.models.provider import Provider from notifiers.models.provider import ProviderResource +from notifiers.models.provider import ResourceSchema from notifiers.models.response import Response +from notifiers.models.response import ResponseStatus from notifiers.providers import _all_providers from notifiers.utils.helpers import text_to_bool -from notifiers.utils.schema.helpers import list_to_commas -from notifiers.utils.schema.helpers import one_or_more log = logging.getLogger(__name__) @@ -25,54 +26,42 @@ class MockProxy: name = "mock_provider" +class MockResourceSchema(ResourceSchema): + key: str = Field(..., description="required key") + another_key: int = Field(None, description="non-required key") + + +class MockProviderSchema(ResourceSchema): + not_required: ResourceSchema.one_or_more_of(str) = Field( + None, description="example for not required arg" + ) + required: str + option_with_default = "foo" + message: str + + @validator("not_required", whole=True) + def csv(cls, v): + return cls.to_list(v) + + class MockResource(MockProxy, ProviderResource): resource_name = "mock_resource" - _required = {"required": ["key"]} - _schema = { - "type": "object", - "properties": { - "key": {"type": "string", "title": "required key"}, - "another_key": {"type": "integer", "title": "non-required key"}, - }, - "additionalProperties": False, - } + schema_model = MockResourceSchema def _get_resource(self, data: dict): - return {"status": SUCCESS_STATUS} + return {"status": ResponseStatus.SUCCESS} class MockProvider(MockProxy, Provider): """Mock Provider""" base_url = "https://api.mock.com" - _required = {"required": ["required"]} - _schema = { - "type": "object", - "properties": { - "not_required": one_or_more( - {"type": "string", "title": "example for not required arg"} - ), - "required": {"type": "string"}, - "option_with_default": {"type": "string"}, - "message": {"type": "string"}, - }, - "additionalProperties": False, - } + schema_model = MockProviderSchema site_url = "https://www.mock.com" - @property - def defaults(self): - return {"option_with_default": "foo"} - def _send_notification(self, data: dict): - return Response(status=SUCCESS_STATUS, provider=self.name, data=data) - - def _prepare_data(self, data: dict): - if data.get("not_required"): - data["not_required"] = list_to_commas(data["not_required"]) - data["required"] = list_to_commas(data["required"]) - return data + return Response(status=ResponseStatus.SUCCESS, provider=self.name, data=data) @property def resources(self): @@ -100,24 +89,6 @@ class BadProvider(Provider): return BadProvider -@pytest.fixture -def bad_schema(): - """Return a provider with an invalid JSON schema""" - - class BadSchema(Provider): - _required = {"required": ["fpp"]} - _schema = {"type": "banana"} - - name = "bad_schmea" - base_url = "" - site_url = "" - - def _send_notification(self, data: dict): - pass - - return BadSchema - - @pytest.fixture(scope="class") def provider(request): name = getattr(request.module, "provider", None) diff --git a/tests/providers/test_popcornnotify.py b/tests/providers/test_popcornnotify.py index 091ac7f7..c0085629 100644 --- a/tests/providers/test_popcornnotify.py +++ b/tests/providers/test_popcornnotify.py @@ -1,8 +1,8 @@ import pytest -from notifiers.core import FAILURE_STATUS from notifiers.exceptions import BadArguments from notifiers.exceptions import NotificationError +from notifiers.models.response import ResponseStatus provider = "popcornnotify" @@ -38,7 +38,7 @@ def test_popcornnotify_sanity(self, provider, test_message): def test_popcornnotify_error(self, provider): data = {"message": "foo", "api_key": "foo", "recipients": "foo@foo.com"} rsp = provider.notify(**data) - assert rsp.status == FAILURE_STATUS + assert rsp.status is ResponseStatus.FAILURE error = "Please provide a valid API key" assert error in rsp.errors with pytest.raises(NotificationError, match=error): diff --git a/tests/providers/test_statuspage.py b/tests/providers/test_statuspage.py index 74e33c80..e8ed2310 100644 --- a/tests/providers/test_statuspage.py +++ b/tests/providers/test_statuspage.py @@ -6,9 +6,9 @@ import pytest import requests -from notifiers.core import FAILURE_STATUS from notifiers.exceptions import BadArguments from notifiers.exceptions import ResourceError +from notifiers.models.response import ResponseStatus provider = "statuspage" @@ -93,7 +93,7 @@ def test_data_dependencies(self, added_data, message, provider): def test_errors(self, provider): data = {"api_key": "foo", "page_id": "foo", "message": "foo"} rsp = provider.notify(**data) - assert rsp.status == FAILURE_STATUS + assert rsp.status is ResponseStatus.FAILURE assert "Could not authenticate" in rsp.errors @pytest.mark.online diff --git a/tests/test_core.py b/tests/test_core.py index d1273276..cd0b5e17 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -6,7 +6,6 @@ from notifiers.exceptions import BadArguments from notifiers.exceptions import NoSuchNotifierError from notifiers.exceptions import NotificationError -from notifiers.exceptions import SchemaError from notifiers.models.provider import Provider from notifiers.models.response import Response @@ -67,11 +66,6 @@ def test_schema_validation(self, data, mock_provider): with pytest.raises(BadArguments): mock_provider.notify(**data) - def test_bad_schema(self, bad_schema): - """Test illegal JSON schema""" - with pytest.raises(SchemaError): - bad_schema() - def test_prepare_data(self, mock_provider): """Test ``prepare_data()`` method""" rsp = mock_provider.notify(**self.valid_data) From 3c11482b7e5e8c1b9e3ecdcddc94a32812e18fd5 Mon Sep 17 00:00:00 2001 From: liiight Date: Mon, 9 Mar 2020 00:28:37 +0200 Subject: [PATCH 080/137] removed all references to all status consts --- notifiers/core.py | 2 -- tests/test_core.py | 12 ++++++------ 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/notifiers/core.py b/notifiers/core.py index 7a99e95a..488a83f5 100644 --- a/notifiers/core.py +++ b/notifiers/core.py @@ -6,8 +6,6 @@ log = logging.getLogger("notifiers") -SUCCESS_STATUS = "Success" - # Avoid premature import from .providers import _all_providers # noqa: E402 diff --git a/tests/test_core.py b/tests/test_core.py index cd0b5e17..4f04fc5b 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2,12 +2,12 @@ import notifiers from notifiers import notify -from notifiers.core import SUCCESS_STATUS from notifiers.exceptions import BadArguments from notifiers.exceptions import NoSuchNotifierError from notifiers.exceptions import NotificationError from notifiers.models.provider import Provider from notifiers.models.response import Response +from notifiers.models.response import ResponseStatus class TestCore: @@ -49,7 +49,7 @@ def test_sanity(self, mock_provider): assert rsp.raise_on_errors() is None assert ( repr(rsp) - == f"" + == f"" ) assert repr(mock_provider) == "" @@ -126,7 +126,7 @@ def test_environs(self, mock_provider, monkeypatch): prefix = f"mock_" monkeypatch.setenv(f"{prefix}{mock_provider.name}_required".upper(), "foo") rsp = mock_provider.notify(env_prefix=prefix) - assert rsp.status == SUCCESS_STATUS + assert rsp.status is ResponseStatus.SUCCESS assert rsp.data["required"] == "foo" def test_provided_data_takes_precedence_over_environ( @@ -136,7 +136,7 @@ def test_provided_data_takes_precedence_over_environ( prefix = f"mock_" monkeypatch.setenv(f"{prefix}{mock_provider.name}_required".upper(), "foo") rsp = mock_provider.notify(required="bar", env_prefix=prefix) - assert rsp.status == SUCCESS_STATUS + assert rsp.status is ResponseStatus.SUCCESS assert rsp.data["required"] == "bar" def test_resources(self, mock_provider): @@ -170,12 +170,12 @@ def test_resources(self, mock_provider): resource() rsp = resource(key="fpp") - assert rsp == {"status": SUCCESS_STATUS} + assert rsp == {"status": ResponseStatus.SUCCESS} def test_direct_notify_positive(self, mock_provider): rsp = notify(mock_provider.name, required="foo", message="foo") assert not rsp.errors - assert rsp.status == SUCCESS_STATUS + assert rsp.status is ResponseStatus.SUCCESS assert rsp.data == { "required": "foo", "message": "foo", From aa914b9813f16f969ac3b27ad8c3760ba1acaaa6 Mon Sep 17 00:00:00 2001 From: liiight Date: Mon, 9 Mar 2020 00:36:48 +0200 Subject: [PATCH 081/137] fixing sanity tests --- tests/test_cli.py | 1 + tests/test_core.py | 32 +++++++++++++++----------------- 2 files changed, 16 insertions(+), 17 deletions(-) diff --git a/tests/test_cli.py b/tests/test_cli.py index 10998637..5a1f27b3 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -8,6 +8,7 @@ @pytest.mark.usefixtures("mock_provider") +@pytest.mark.skip("Need to fix CLI") class TestCLI: """CLI tests""" diff --git a/tests/test_core.py b/tests/test_core.py index 4f04fc5b..5f4ba993 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -13,7 +13,7 @@ class TestCore: """Test core classes""" - valid_data = {"required": "foo", "not_required": ["foo", "bar"]} + valid_data = {"required": "foo", "not_required": ["foo", "bar"], "message": "foo"} def test_sanity(self, mock_provider): """Test basic notification flow""" @@ -24,25 +24,23 @@ def test_sanity(self, mock_provider): } assert mock_provider.arguments == { "not_required": { - "oneOf": [ - { - "type": "array", - "items": { - "type": "string", - "title": "example for not required arg", - }, - "minItems": 1, - "uniqueItems": True, - }, - {"type": "string", "title": "example for not required arg"}, - ] + "title": "Not Required", + "description": "example for not required arg", + "anyOf": [ + {"type": "array", "items": {"type": "string"}}, + {"type": "string"}, + ], + }, + "required": {"title": "Required", "type": "string"}, + "message": {"title": "Message", "type": "string"}, + "option_with_default": { + "title": "Option With Default", + "default": "foo", + "type": "string", }, - "required": {"type": "string"}, - "option_with_default": {"type": "string"}, - "message": {"type": "string"}, } - assert mock_provider.required == {"required": ["required"]} + assert mock_provider.required == ["required", "message"] rsp = mock_provider.notify(**self.valid_data) assert isinstance(rsp, Response) assert not rsp.errors From 56c2b460cda2e94b38cb94ca097c18e5e419fe78 Mon Sep 17 00:00:00 2001 From: liiight Date: Mon, 9 Mar 2020 00:52:08 +0200 Subject: [PATCH 082/137] fixed core tests --- notifiers/models/provider.py | 2 +- tests/conftest.py | 25 +++++++++---------------- tests/test_core.py | 29 ++++++++++++++++------------- 3 files changed, 26 insertions(+), 30 deletions(-) diff --git a/notifiers/models/provider.py b/notifiers/models/provider.py index 6f400c2f..87a9e5c2 100644 --- a/notifiers/models/provider.py +++ b/notifiers/models/provider.py @@ -24,7 +24,7 @@ class ResourceSchema(BaseModel): """The base class for Schemas""" - _values_to_exclude: Tuple[str, ...] + _values_to_exclude: Tuple[str, ...] = () @staticmethod def to_list(value: Union[Any, List[Any]]) -> List[Any]: diff --git a/tests/conftest.py b/tests/conftest.py index 56773a14..2f44f2e0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -7,6 +7,7 @@ import pytest from click.testing import CliRunner from pydantic import Field +from pydantic import StrictStr from pydantic import validator from notifiers.core import get_notifier @@ -35,13 +36,13 @@ class MockProviderSchema(ResourceSchema): not_required: ResourceSchema.one_or_more_of(str) = Field( None, description="example for not required arg" ) - required: str + required: StrictStr option_with_default = "foo" - message: str + message: str = None @validator("not_required", whole=True) def csv(cls, v): - return cls.to_list(v) + return cls.to_comma_separated(v) class MockResource(MockProxy, ProviderResource): @@ -57,11 +58,13 @@ class MockProvider(MockProxy, Provider): """Mock Provider""" base_url = "https://api.mock.com" - schema_model = MockProviderSchema site_url = "https://www.mock.com" + schema_model = MockProviderSchema - def _send_notification(self, data: dict): - return Response(status=ResponseStatus.SUCCESS, provider=self.name, data=data) + def _send_notification(self, data: MockProviderSchema): + return Response( + status=ResponseStatus.SUCCESS, provider=self.name, data=data.to_dict() + ) @property def resources(self): @@ -79,16 +82,6 @@ def mock_provider(): return MockProvider() -@pytest.fixture -def bad_provider(): - """Returns an unimplemented :class:`notifiers.core.Provider` class for testing""" - - class BadProvider(Provider): - pass - - return BadProvider - - @pytest.fixture(scope="class") def provider(request): name = getattr(request.module, "provider", None) diff --git a/tests/test_core.py b/tests/test_core.py index 5f4ba993..f64023c1 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -40,7 +40,7 @@ def test_sanity(self, mock_provider): }, } - assert mock_provider.required == ["required", "message"] + assert mock_provider.required == ["required"] rsp = mock_provider.notify(**self.valid_data) assert isinstance(rsp, Response) assert not rsp.errors @@ -71,6 +71,7 @@ def test_prepare_data(self, mock_provider): "not_required": "foo,bar", "required": "foo", "option_with_default": "foo", + "message": "foo", } def test_get_notifier(self, mock_provider): @@ -106,19 +107,11 @@ def test_error_response(self, mock_provider): "not_required": "foo,bar", "required": "foo", "option_with_default": "foo", + "message": "foo", } assert e.value.message == "Notification errors: an error" assert e.value.provider == mock_provider.name - def test_bad_integration(self, bad_provider): - """Test bad provider inheritance""" - with pytest.raises(TypeError) as e: - bad_provider() - assert ( - "Can't instantiate abstract class BadProvider with abstract methods _required," - " _schema, _send_notification, base_url, name, site_url" - ) in str(e.value) - def test_environs(self, mock_provider, monkeypatch): """Test environs usage""" prefix = f"mock_" @@ -153,16 +146,26 @@ def test_resources(self, mock_provider): assert resource.resource_name == "mock_resource" assert resource.name == mock_provider.name assert resource.schema == { + "title": "MockResourceSchema", + "description": "The base class for Schemas", "type": "object", "properties": { - "key": {"type": "string", "title": "required key"}, - "another_key": {"type": "integer", "title": "non-required key"}, + "key": { + "title": "Key", + "description": "required key", + "type": "string", + }, + "another_key": { + "title": "Another Key", + "description": "non-required key", + "type": "integer", + }, }, "required": ["key"], "additionalProperties": False, } - assert resource.required == {"required": ["key"]} + assert resource.required == ["key"] with pytest.raises(BadArguments): resource() From 9dbda3f64b8f66c4eb55d7bdb0c27972fd1c922b Mon Sep 17 00:00:00 2001 From: liiight Date: Tue, 10 Mar 2020 00:44:09 +0200 Subject: [PATCH 083/137] fixed file list tests --- tests/test_utils.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index c09e9785..4ef5ef07 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -71,12 +71,12 @@ def test_valid_file(self, tmpdir): assert not valid_file(dir_) assert not valid_file(no_file) - def test_file_list_for_request(self, tmpdir): - file_1 = tmpdir.join("file_1") - file_2 = tmpdir.join("file_2") + def test_file_list_for_request(self, tmp_path): + file_1 = tmp_path / "file_1" + file_2 = tmp_path / "file_2" - file_1.write("foo") - file_2.write("foo") + file_1.write_text("foo") + file_2.write_text("foo") file_list = file_list_for_request([file_1, file_2], "foo") assert len(file_list) == 2 From 5f89fd72fd878f698fe6e3d2c8510b92ac327d64 Mon Sep 17 00:00:00 2001 From: liiight Date: Tue, 10 Mar 2020 00:46:23 +0200 Subject: [PATCH 084/137] fixed some more tests --- tests/providers/test_join.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/tests/providers/test_join.py b/tests/providers/test_join.py index e0b9960b..00e888d2 100644 --- a/tests/providers/test_join.py +++ b/tests/providers/test_join.py @@ -24,9 +24,6 @@ def test_missing_required(self, data, message, provider): provider.notify(**data) assert f"'{message}' is a required property" in e.value.message - def test_defaults(self, provider): - assert provider.defaults == {"deviceId": "group.all"} - @pytest.mark.skip("tests fail due to no device connected") @pytest.mark.online def test_sanity(self, provider): From 731ee51c351504ae1eef7493c667394273aab4a9 Mon Sep 17 00:00:00 2001 From: liiight Date: Tue, 10 Mar 2020 00:48:12 +0200 Subject: [PATCH 085/137] removed redundant utils --- notifiers/utils/schema/__init__.py | 0 notifiers/utils/schema/formats.py | 77 ------------------------- notifiers/utils/schema/helpers.py | 35 ------------ tests/test_json_schema.py | 91 ------------------------------ tests/test_utils.py | 14 ----- 5 files changed, 217 deletions(-) delete mode 100644 notifiers/utils/schema/__init__.py delete mode 100644 notifiers/utils/schema/formats.py delete mode 100644 notifiers/utils/schema/helpers.py delete mode 100644 tests/test_json_schema.py diff --git a/notifiers/utils/schema/__init__.py b/notifiers/utils/schema/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/notifiers/utils/schema/formats.py b/notifiers/utils/schema/formats.py deleted file mode 100644 index e34d7c7a..00000000 --- a/notifiers/utils/schema/formats.py +++ /dev/null @@ -1,77 +0,0 @@ -import email -import re -from datetime import datetime - -import jsonschema - -from notifiers.utils.helpers import valid_file - -# Taken from https://gist.github.com/codehack/6350492822e52b7fa7fe -ISO8601 = re.compile( - r"^(?P(" - r"(?P\d{4})([/-]?" - r"(?P(0[1-9])|(1[012]))([/-]?" - r"(?P(0[1-9])|([12]\d)|(3[01])))?)?(?:T" - r"(?P([01][0-9])|(?:2[0123]))(:?" - r"(?P[0-5][0-9])(:?" - r"(?P[0-5][0-9]([,.]\d{1,10})?))?)?" - r"(?:Z|([\-+](?:([01][0-9])|(?:2[0123]))(:?(?:[0-5][0-9]))?))?)?))$" -) -E164 = re.compile(r"^\+?[1-9]\d{1,14}$") -format_checker = jsonschema.FormatChecker() - - -@format_checker.checks("iso8601", raises=ValueError) -def is_iso8601(instance: str): - """Validates ISO8601 format""" - if not isinstance(instance, str): - return True - return ISO8601.match(instance) is not None - - -@format_checker.checks("rfc2822", raises=ValueError) -def is_rfc2822(instance: str): - """Validates RFC2822 format""" - if not isinstance(instance, str): - return True - return email.utils.parsedate(instance) is not None - - -@format_checker.checks("ascii", raises=ValueError) -def is_ascii(instance: str): - """Validates data is ASCII encodable""" - if not isinstance(instance, str): - return True - return instance.encode("ascii") - - -@format_checker.checks("valid_file", raises=ValueError) -def is_valid_file(instance: str): - """Validates data is a valid file""" - if not isinstance(instance, str): - return True - return valid_file(instance) - - -@format_checker.checks("port", raises=ValueError) -def is_valid_port(instance: int): - """Validates data is a valid port""" - if not isinstance(instance, (int, str)): - return True - return int(instance) in range(65535) - - -@format_checker.checks("timestamp", raises=ValueError) -def is_timestamp(instance): - """Validates data is a timestamp""" - if not isinstance(instance, (int, str)): - return True - return datetime.fromtimestamp(int(instance)) - - -@format_checker.checks("e164", raises=ValueError) -def is_e164(instance): - """Validates data is E.164 format""" - if not isinstance(instance, str): - return True - return E164.match(instance) is not None diff --git a/notifiers/utils/schema/helpers.py b/notifiers/utils/schema/helpers.py deleted file mode 100644 index 0e4e37df..00000000 --- a/notifiers/utils/schema/helpers.py +++ /dev/null @@ -1,35 +0,0 @@ -def one_or_more( - schema: dict, unique_items: bool = True, min: int = 1, max: int = None -) -> dict: - """ - Helper function to construct a schema that validates items matching - `schema` or an array containing items matching `schema`. - - :param schema: The schema to use - :param unique_items: Flag if array items should be unique - :param min: Correlates to ``minLength`` attribute of JSON Schema array - :param max: Correlates to ``maxLength`` attribute of JSON Schema array - """ - multi_schema = { - "type": "array", - "items": schema, - "minItems": min, - "uniqueItems": unique_items, - } - if max: - multi_schema["maxItems"] = max - return {"oneOf": [multi_schema, schema]} - - -def list_to_commas(list_of_args) -> str: - """ - Converts a list of items to a comma separated list. If ``list_of_args`` is - not a list, just return it back - - :param list_of_args: List of items - :return: A string representing a comma separated list. - """ - if isinstance(list_of_args, list): - return ",".join(list_of_args) - return list_of_args - # todo change or create a new util that handle conversion to list as well diff --git a/tests/test_json_schema.py b/tests/test_json_schema.py deleted file mode 100644 index 8d7b1144..00000000 --- a/tests/test_json_schema.py +++ /dev/null @@ -1,91 +0,0 @@ -import hypothesis.strategies as st -import pytest -from hypothesis import given -from jsonschema import validate -from jsonschema import ValidationError - -from notifiers.utils.schema.formats import format_checker -from notifiers.utils.schema.helpers import list_to_commas -from notifiers.utils.schema.helpers import one_or_more - - -class TestFormats: - @pytest.mark.parametrize( - "formatter, value", - [ - ("iso8601", "2018-07-15T07:39:59+00:00"), - ("iso8601", "2018-07-15T07:39:59Z"), - ("iso8601", "20180715T073959Z"), - ("rfc2822", "Thu, 25 Dec 1975 14:15:16 -0500"), - ("ascii", "foo"), - ("port", "44444"), - ("port", 44_444), - ("timestamp", 1531644024), - ("timestamp", "1531644024"), - ("e164", "+14155552671"), - ("e164", "+442071838750"), - ("e164", "+551155256325"), - ], - ) - def test_format_positive(self, formatter, value): - validate(value, {"format": formatter}, format_checker=format_checker) - - def test_valid_file_format(self, tmpdir): - file_1 = tmpdir.mkdir("foo").join("file_1") - file_1.write("bar") - - validate(str(file_1), {"format": "valid_file"}, format_checker=format_checker) - - @pytest.mark.parametrize( - "formatter, value", - [ - ("iso8601", "2018-14-15T07:39:59+00:00"), - ("iso8601", "2018-07-15T07:39:59Z~"), - ("iso8601", "20180715T0739545639Z"), - ("rfc2822", "Thu 25 Dec14:15:16 -0500"), - ("ascii", "פו"), - ("port", "70000"), - ("port", 70_000), - ("timestamp", "15565-5631644024"), - ("timestamp", "155655631644024"), - ("e164", "-14155552671"), - ("e164", "+44207183875063673465"), - ("e164", "+551155256325zdfgsd"), - ], - ) - def test_format_negative(self, formatter, value): - with pytest.raises(ValidationError): - validate(value, {"format": formatter}, format_checker=format_checker) - - -class TestSchemaUtils: - @pytest.mark.parametrize( - "input_schema, unique_items, min, max, data", - [ - ({"type": "string"}, True, 1, 1, "foo"), - ({"type": "string"}, True, 1, 2, ["foo", "bar"]), - ({"type": "integer"}, True, 1, 2, 1), - ({"type": "integer"}, True, 1, 2, [1, 2]), - ], - ) - def test_one_or_more_positive(self, input_schema, unique_items, min, max, data): - expected_schema = one_or_more(input_schema, unique_items, min, max) - validate(data, expected_schema) - - @pytest.mark.parametrize( - "input_schema, unique_items, min, max, data", - [ - ({"type": "string"}, True, 1, 1, 1), - ({"type": "string"}, True, 1, 1, ["foo", "bar"]), - ({"type": "integer"}, False, 3, None, [1, 1]), - ({"type": "integer"}, True, 1, 1, [1, 2]), - ], - ) - def test_one_or_more_negative(self, input_schema, unique_items, min, max, data): - expected_schema = one_or_more(input_schema, unique_items, min, max) - with pytest.raises(ValidationError): - validate(data, expected_schema) - - @given(st.lists(st.text())) - def test_list_to_commas(self, input_data): - assert list_to_commas(input_data) == ",".join(input_data) diff --git a/tests/test_utils.py b/tests/test_utils.py index 4ef5ef07..6b317231 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,7 +4,6 @@ from notifiers.utils.helpers import merge_dicts from notifiers.utils.helpers import snake_to_camel_case from notifiers.utils.helpers import text_to_bool -from notifiers.utils.helpers import valid_file from notifiers.utils.requests import file_list_for_request @@ -58,19 +57,6 @@ def test_dict_from_environs(self, prefix, name, args, result, monkeypatch): def test_snake_to_camel_case(self, snake_value, cc_value): assert snake_to_camel_case(snake_value) == cc_value - def test_valid_file(self, tmpdir): - dir_ = str(tmpdir) - - file = tmpdir.join("foo.txt") - file.write("foo") - file = str(file) - - no_file = "foo" - - assert valid_file(file) - assert not valid_file(dir_) - assert not valid_file(no_file) - def test_file_list_for_request(self, tmp_path): file_1 = tmp_path / "file_1" file_2 = tmp_path / "file_2" From 9935ee1f7534a52225eedffce3aeaab2398bd135 Mon Sep 17 00:00:00 2001 From: liiight Date: Tue, 10 Mar 2020 00:51:17 +0200 Subject: [PATCH 086/137] removed another unused helper --- notifiers/utils/helpers.py | 13 ------------- 1 file changed, 13 deletions(-) diff --git a/notifiers/utils/helpers.py b/notifiers/utils/helpers.py index e351956f..dbe54b87 100644 --- a/notifiers/utils/helpers.py +++ b/notifiers/utils/helpers.py @@ -1,7 +1,6 @@ import logging import os from distutils.util import strtobool -from pathlib import Path log = logging.getLogger("notifiers") @@ -62,15 +61,3 @@ def snake_to_camel_case(value: str) -> str: """ log.debug("trying to convert %s to camel case", value) return "".join(word.capitalize() for word in value.split("_")) - - -def valid_file(path: str) -> bool: - """ - Verifies that a string path actually exists and is a file - - :param path: The path to verify - :return: **True** if path exist and is a file - """ - path = Path(path).expanduser() - log.debug("checking if %s is a valid file", path) - return path.exists() and path.is_file() From 6a06f6231e7f310c0811eb8eb49eede20abce2ff Mon Sep 17 00:00:00 2001 From: liiight Date: Tue, 10 Mar 2020 00:55:51 +0200 Subject: [PATCH 087/137] tweak --- notifiers/utils/helpers.py | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/notifiers/utils/helpers.py b/notifiers/utils/helpers.py index dbe54b87..c4c045e1 100644 --- a/notifiers/utils/helpers.py +++ b/notifiers/utils/helpers.py @@ -28,8 +28,7 @@ def merge_dicts(target_dict: dict, merge_dict: dict) -> dict: """ log.debug("merging dict %s into %s", merge_dict, target_dict) for key, value in merge_dict.items(): - if key not in target_dict: - target_dict[key] = value + target_dict.setdefault(key, value) return target_dict @@ -43,13 +42,12 @@ def dict_from_environs(prefix: str, name: str, args: list) -> dict: :param args: List of args to iterate over :return: A dict of found environ values """ - environs = {} log.debug("starting to collect environs using prefix: '%s'", prefix) - for arg in args: - environ = f"{prefix}{name}_{arg}".upper() - if os.environ.get(environ): - environs[arg] = os.environ[environ] - return environs + return { + arg: os.environ.get(f"{prefix}{name}_{arg}".upper()) + for arg in args + if os.environ.get(f"{prefix}{name}_{arg}".upper()) + } def snake_to_camel_case(value: str) -> str: @@ -59,5 +57,5 @@ def snake_to_camel_case(value: str) -> str: :param value: The value to convert :return: A CamelCase value """ - log.debug("trying to convert %s to camel case", value) + log.debug("converting %s to camel case", value) return "".join(word.capitalize() for word in value.split("_")) From 73497ea0c0f02e30375412ddb649c68e3516ab74 Mon Sep 17 00:00:00 2001 From: liiight Date: Tue, 10 Mar 2020 00:59:42 +0200 Subject: [PATCH 088/137] name tweak --- notifiers/core.py | 2 +- notifiers/models/{provider.py => resource.py} | 69 ++----------------- notifiers/models/schema.py | 62 +++++++++++++++++ notifiers/providers/email.py | 4 +- notifiers/providers/gitter.py | 6 +- notifiers/providers/join.py | 6 +- notifiers/providers/mailgun.py | 4 +- notifiers/providers/pagerduty.py | 4 +- notifiers/providers/popcornnotify.py | 4 +- notifiers/providers/pushbullet.py | 6 +- notifiers/providers/pushover.py | 6 +- notifiers/providers/simplepush.py | 4 +- notifiers/providers/slack/blocks.py | 2 +- notifiers/providers/slack/composition.py | 2 +- notifiers/providers/slack/elements.py | 2 +- notifiers/providers/slack/main.py | 4 +- notifiers/providers/statuspage.py | 6 +- notifiers/providers/telegram.py | 6 +- notifiers/providers/twilio.py | 4 +- notifiers/providers/zulip.py | 4 +- tests/conftest.py | 6 +- tests/test_core.py | 2 +- 22 files changed, 109 insertions(+), 106 deletions(-) rename notifiers/models/{provider.py => resource.py} (70%) create mode 100644 notifiers/models/schema.py diff --git a/notifiers/core.py b/notifiers/core.py index 488a83f5..fc1809a9 100644 --- a/notifiers/core.py +++ b/notifiers/core.py @@ -1,7 +1,7 @@ import logging from .exceptions import NoSuchNotifierError -from .models.provider import Provider +from .models.resource import Provider from .models.response import Response log = logging.getLogger("notifiers") diff --git a/notifiers/models/provider.py b/notifiers/models/resource.py similarity index 70% rename from notifiers/models/provider.py rename to notifiers/models/resource.py index 87a9e5c2..797144dd 100644 --- a/notifiers/models/provider.py +++ b/notifiers/models/resource.py @@ -1,82 +1,23 @@ -import json from abc import ABC from abc import abstractmethod -from typing import Any from typing import List from typing import Optional -from typing import Tuple -from typing import Union import requests -from pydantic import BaseModel -from pydantic import Extra from pydantic import ValidationError from notifiers.exceptions import BadArguments from notifiers.models.response import Response from notifiers.models.response import ResponseStatus +from notifiers.models.schema import ResourceSchema from notifiers.utils.helpers import dict_from_environs from notifiers.utils.helpers import merge_dicts DEFAULT_ENVIRON_PREFIX = "NOTIFIERS_" -class ResourceSchema(BaseModel): - """The base class for Schemas""" - - _values_to_exclude: Tuple[str, ...] = () - - @staticmethod - def to_list(value: Union[Any, List[Any]]) -> List[Any]: - # todo convert this to a custom type instead of a helper method - """Helper method to make sure a return value is a list""" - if not isinstance(value, list): - return [value] - return value - - @staticmethod - def to_comma_separated(values: Union[Any, List[Any]]) -> str: - # todo convert this to a custom type instead of a helper method - """Helper method that return a comma separates string from a value""" - if not isinstance(values, list): - values = [values] - return ",".join(str(value) for value in values) - - @staticmethod - def one_or_more_of(type_: Any) -> Union[List[Any], Any]: - """A helper method that returns the relevant type to specify that one or more of the given type can be used - in a schema""" - return Union[List[type_], type_] - - def to_dict( - self, exclude_none: bool = True, by_alias: bool = True, **kwargs - ) -> dict: - """ - A helper method to a very common dict builder. - Round tripping to json and back to dict is needed since the model can contain special object that need - to be transformed to json first (like enums) - - :param exclude_none: Should values that are `None` be part of the payload - :param by_alias: Use the field name of its alias name (if exists) - :param kwargs: Additional options. See https://pydantic-docs.helpmanual.io/usage/exporting_models/ - :return: dict payload of the schema - """ - return json.loads( - self.json( - exclude_none=exclude_none, - by_alias=by_alias, - exclude=set(self._values_to_exclude), - **kwargs, - ) - ) - - class Config: - allow_population_by_field_name = True - extra = Extra.forbid - - -class SchemaResource(ABC): - """Base class that represent an object schema and its utility methods""" +class Resource(ABC): + """Base class that represent an object holding a schema and its utility methods""" schema_model: ResourceSchema @@ -152,7 +93,7 @@ def _process_data(self, data: dict) -> ResourceSchema: return data -class Provider(SchemaResource, ABC): +class Provider(Resource, ABC): """The Base class all notification providers inherit from.""" _resources = {} @@ -213,7 +154,7 @@ def notify(self, raise_on_errors: bool = False, **kwargs) -> Response: return rsp -class ProviderResource(SchemaResource, ABC): +class ProviderResource(Resource, ABC): """The base class that is used to fetch provider related resources like rooms, channels, users etc.""" @property diff --git a/notifiers/models/schema.py b/notifiers/models/schema.py new file mode 100644 index 00000000..3386ef28 --- /dev/null +++ b/notifiers/models/schema.py @@ -0,0 +1,62 @@ +import json +from typing import Any +from typing import List +from typing import Tuple +from typing import Union + +from pydantic import BaseModel +from pydantic import Extra + + +class ResourceSchema(BaseModel): + """The base class for Schemas""" + + _values_to_exclude: Tuple[str, ...] = () + + @staticmethod + def to_list(value: Union[Any, List[Any]]) -> List[Any]: + # todo convert this to a custom type instead of a helper method + """Helper method to make sure a return value is a list""" + if not isinstance(value, list): + return [value] + return value + + @staticmethod + def to_comma_separated(values: Union[Any, List[Any]]) -> str: + # todo convert this to a custom type instead of a helper method + """Helper method that return a comma separates string from a value""" + if not isinstance(values, list): + values = [values] + return ",".join(str(value) for value in values) + + @staticmethod + def one_or_more_of(type_: Any) -> Union[List[Any], Any]: + """A helper method that returns the relevant type to specify that one or more of the given type can be used + in a schema""" + return Union[List[type_], type_] + + def to_dict( + self, exclude_none: bool = True, by_alias: bool = True, **kwargs + ) -> dict: + """ + A helper method to a very common dict builder. + Round tripping to json and back to dict is needed since the model can contain special object that need + to be transformed to json first (like enums) + + :param exclude_none: Should values that are `None` be part of the payload + :param by_alias: Use the field name of its alias name (if exists) + :param kwargs: Additional options. See https://pydantic-docs.helpmanual.io/usage/exporting_models/ + :return: dict payload of the schema + """ + return json.loads( + self.json( + exclude_none=exclude_none, + by_alias=by_alias, + exclude=set(self._values_to_exclude), + **kwargs, + ) + ) + + class Config: + allow_population_by_field_name = True + extra = Extra.forbid diff --git a/notifiers/providers/email.py b/notifiers/providers/email.py index d75663ee..cb48bd37 100644 --- a/notifiers/providers/email.py +++ b/notifiers/providers/email.py @@ -17,9 +17,9 @@ from pydantic import root_validator from pydantic import validator -from ..models.provider import Provider -from ..models.provider import ResourceSchema +from ..models.resource import Provider from ..models.response import Response +from ..models.schema import ResourceSchema class SMTPSchema(ResourceSchema): diff --git a/notifiers/providers/gitter.py b/notifiers/providers/gitter.py index 11aadca6..48b35366 100644 --- a/notifiers/providers/gitter.py +++ b/notifiers/providers/gitter.py @@ -3,10 +3,10 @@ from pydantic import Field from ..exceptions import ResourceError -from ..models.provider import Provider -from ..models.provider import ProviderResource -from ..models.provider import ResourceSchema +from ..models.resource import Provider +from ..models.resource import ProviderResource from ..models.response import Response +from ..models.schema import ResourceSchema from ..utils import requests diff --git a/notifiers/providers/join.py b/notifiers/providers/join.py index 065be34c..42a87084 100644 --- a/notifiers/providers/join.py +++ b/notifiers/providers/join.py @@ -11,10 +11,10 @@ from pydantic import validator from ..exceptions import ResourceError -from ..models.provider import Provider -from ..models.provider import ProviderResource -from ..models.provider import ResourceSchema +from ..models.resource import Provider +from ..models.resource import ProviderResource from ..models.response import Response +from ..models.schema import ResourceSchema class JoinGroup(str, Enum): diff --git a/notifiers/providers/mailgun.py b/notifiers/providers/mailgun.py index 5e830d14..07cfd5a8 100644 --- a/notifiers/providers/mailgun.py +++ b/notifiers/providers/mailgun.py @@ -15,9 +15,9 @@ from pydantic import validator from typing_extensions import Literal -from ..models.provider import Provider -from ..models.provider import ResourceSchema +from ..models.resource import Provider from ..models.response import Response +from ..models.schema import ResourceSchema from ..utils import requests diff --git a/notifiers/providers/pagerduty.py b/notifiers/providers/pagerduty.py index 75c1f94a..5c05fa66 100644 --- a/notifiers/providers/pagerduty.py +++ b/notifiers/providers/pagerduty.py @@ -7,9 +7,9 @@ from pydantic import HttpUrl from pydantic import validator -from ..models.provider import Provider -from ..models.provider import ResourceSchema +from ..models.resource import Provider from ..models.response import Response +from ..models.schema import ResourceSchema from ..utils import requests diff --git a/notifiers/providers/popcornnotify.py b/notifiers/providers/popcornnotify.py index 01bb05ee..7cf3004b 100644 --- a/notifiers/providers/popcornnotify.py +++ b/notifiers/providers/popcornnotify.py @@ -1,9 +1,9 @@ from pydantic import Field from pydantic import validator -from ..models.provider import Provider -from ..models.provider import ResourceSchema +from ..models.resource import Provider from ..models.response import Response +from ..models.schema import ResourceSchema from ..utils import requests diff --git a/notifiers/providers/pushbullet.py b/notifiers/providers/pushbullet.py index 4a540682..6a056cc8 100644 --- a/notifiers/providers/pushbullet.py +++ b/notifiers/providers/pushbullet.py @@ -9,10 +9,10 @@ from pydantic import root_validator from ..exceptions import ResourceError -from ..models.provider import Provider -from ..models.provider import ProviderResource -from ..models.provider import ResourceSchema +from ..models.resource import Provider +from ..models.resource import ProviderResource from ..models.response import Response +from ..models.schema import ResourceSchema from ..utils import requests diff --git a/notifiers/providers/pushover.py b/notifiers/providers/pushover.py index 953c8067..53b44482 100644 --- a/notifiers/providers/pushover.py +++ b/notifiers/providers/pushover.py @@ -10,10 +10,10 @@ from pydantic import validator from ..exceptions import ResourceError -from ..models.provider import Provider -from ..models.provider import ProviderResource -from ..models.provider import ResourceSchema +from ..models.resource import Provider +from ..models.resource import ProviderResource from ..models.response import Response +from ..models.schema import ResourceSchema from ..utils import requests diff --git a/notifiers/providers/simplepush.py b/notifiers/providers/simplepush.py index c408775f..156acbe1 100644 --- a/notifiers/providers/simplepush.py +++ b/notifiers/providers/simplepush.py @@ -1,8 +1,8 @@ from pydantic import Field -from ..models.provider import Provider -from ..models.provider import ResourceSchema +from ..models.resource import Provider from ..models.response import Response +from ..models.schema import ResourceSchema from ..utils import requests diff --git a/notifiers/providers/slack/blocks.py b/notifiers/providers/slack/blocks.py index 6389ba0f..11801241 100644 --- a/notifiers/providers/slack/blocks.py +++ b/notifiers/providers/slack/blocks.py @@ -8,6 +8,7 @@ from pydantic import root_validator from typing_extensions import Literal +from ...models.schema import ResourceSchema from .elements import ButtonElement from .elements import CheckboxElement from .elements import DatePickerElement @@ -23,7 +24,6 @@ from .elements import SelectChannelsElement from .elements import SelectUsersElement from .elements import StaticSelectElement -from notifiers.models.provider import ResourceSchema from notifiers.providers.slack.composition import _text_object_factory from notifiers.providers.slack.composition import Text from notifiers.providers.slack.composition import TextType diff --git a/notifiers/providers/slack/composition.py b/notifiers/providers/slack/composition.py index 8cdf4c13..c0775e9a 100644 --- a/notifiers/providers/slack/composition.py +++ b/notifiers/providers/slack/composition.py @@ -9,7 +9,7 @@ from pydantic import root_validator from typing_extensions import Literal -from notifiers.models.provider import ResourceSchema +from notifiers.models.schema import ResourceSchema class TextType(Enum): diff --git a/notifiers/providers/slack/elements.py b/notifiers/providers/slack/elements.py index 4c495b4e..2a3326ec 100644 --- a/notifiers/providers/slack/elements.py +++ b/notifiers/providers/slack/elements.py @@ -8,7 +8,7 @@ from pydantic import PositiveInt from pydantic import root_validator -from notifiers.models.provider import ResourceSchema +from notifiers.models.schema import ResourceSchema from notifiers.providers.slack.composition import _text_object_factory from notifiers.providers.slack.composition import ConfirmationDialog from notifiers.providers.slack.composition import Option diff --git a/notifiers/providers/slack/main.py b/notifiers/providers/slack/main.py index f6e5c4e9..de281634 100644 --- a/notifiers/providers/slack/main.py +++ b/notifiers/providers/slack/main.py @@ -9,9 +9,9 @@ from pydantic import validator from pydantic.color import Color as ColorType -from notifiers.models.provider import Provider -from notifiers.models.provider import ResourceSchema +from notifiers.models.resource import Provider from notifiers.models.response import Response +from notifiers.models.schema import ResourceSchema from notifiers.providers.slack.blocks import Blocks from notifiers.providers.slack.composition import Color from notifiers.utils import requests diff --git a/notifiers/providers/statuspage.py b/notifiers/providers/statuspage.py index 2c890abc..40da6e76 100644 --- a/notifiers/providers/statuspage.py +++ b/notifiers/providers/statuspage.py @@ -9,10 +9,10 @@ from pydantic.json import isoformat from ..exceptions import ResourceError -from ..models.provider import Provider -from ..models.provider import ProviderResource -from ..models.provider import ResourceSchema +from ..models.resource import Provider +from ..models.resource import ProviderResource from ..models.response import Response +from ..models.schema import ResourceSchema from ..utils import requests diff --git a/notifiers/providers/telegram.py b/notifiers/providers/telegram.py index 5f6ae3e5..a8a64454 100644 --- a/notifiers/providers/telegram.py +++ b/notifiers/providers/telegram.py @@ -8,10 +8,10 @@ from pydantic import root_validator from ..exceptions import ResourceError -from ..models.provider import Provider -from ..models.provider import ProviderResource -from ..models.provider import ResourceSchema +from ..models.resource import Provider +from ..models.resource import ProviderResource from ..models.response import Response +from ..models.schema import ResourceSchema from ..utils import requests diff --git a/notifiers/providers/twilio.py b/notifiers/providers/twilio.py index e67013b7..21e59455 100644 --- a/notifiers/providers/twilio.py +++ b/notifiers/providers/twilio.py @@ -9,9 +9,9 @@ from pydantic import HttpUrl from pydantic import root_validator -from ..models.provider import Provider -from ..models.provider import ResourceSchema +from ..models.resource import Provider from ..models.response import Response +from ..models.schema import ResourceSchema from ..utils import requests from ..utils.helpers import snake_to_camel_case diff --git a/notifiers/providers/zulip.py b/notifiers/providers/zulip.py index 3095cc52..390559de 100644 --- a/notifiers/providers/zulip.py +++ b/notifiers/providers/zulip.py @@ -10,9 +10,9 @@ from pydantic import ValidationError from pydantic import validator -from ..models.provider import Provider -from ..models.provider import ResourceSchema +from ..models.resource import Provider from ..models.response import Response +from ..models.schema import ResourceSchema from ..utils import requests diff --git a/tests/conftest.py b/tests/conftest.py index 2f44f2e0..efc5b617 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,11 +12,11 @@ from notifiers.core import get_notifier from notifiers.logging import NotificationHandler -from notifiers.models.provider import Provider -from notifiers.models.provider import ProviderResource -from notifiers.models.provider import ResourceSchema +from notifiers.models.resource import Provider +from notifiers.models.resource import ProviderResource from notifiers.models.response import Response from notifiers.models.response import ResponseStatus +from notifiers.models.schema import ResourceSchema from notifiers.providers import _all_providers from notifiers.utils.helpers import text_to_bool diff --git a/tests/test_core.py b/tests/test_core.py index f64023c1..dabe887b 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -5,7 +5,7 @@ from notifiers.exceptions import BadArguments from notifiers.exceptions import NoSuchNotifierError from notifiers.exceptions import NotificationError -from notifiers.models.provider import Provider +from notifiers.models.resource import Provider from notifiers.models.response import Response from notifiers.models.response import ResponseStatus From eec60db8f7790bcbccb3b6cd25347187d9876505 Mon Sep 17 00:00:00 2001 From: liiight Date: Tue, 10 Mar 2020 01:00:56 +0200 Subject: [PATCH 089/137] minor tweak --- notifiers/providers/slack/main.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/notifiers/providers/slack/main.py b/notifiers/providers/slack/main.py index de281634..7476bf9b 100644 --- a/notifiers/providers/slack/main.py +++ b/notifiers/providers/slack/main.py @@ -197,7 +197,6 @@ class Slack(Provider): schema_model = SlackSchema def _send_notification(self, data: SlackSchema) -> Response: - data = data.to_dict() - url = data.pop("webhook_url") - response, errors = requests.post(url, json=data) - return self.create_response(data, response, errors) + payload = data.to_dict() + response, errors = requests.post(data.webhook_url, json=payload) + return self.create_response(payload, response, errors) From d348721f059264f7801893eb08c64de3dafe76c1 Mon Sep 17 00:00:00 2001 From: liiight Date: Tue, 10 Mar 2020 01:09:37 +0200 Subject: [PATCH 090/137] improved consistency --- notifiers/providers/email.py | 2 +- notifiers/providers/join.py | 11 ++++++----- notifiers/providers/pagerduty.py | 6 +++--- notifiers/providers/popcornnotify.py | 6 +++--- notifiers/providers/pushbullet.py | 25 ++++++++++++------------- notifiers/providers/pushover.py | 12 +++++++----- 6 files changed, 32 insertions(+), 30 deletions(-) diff --git a/notifiers/providers/email.py b/notifiers/providers/email.py index cb48bd37..16f68c41 100644 --- a/notifiers/providers/email.py +++ b/notifiers/providers/email.py @@ -149,4 +149,4 @@ def _send_notification(self, data: SMTPSchema) -> Response: SMTPAuthenticationError, ) as e: errors = [str(e)] - return self.create_response(data.dict(), errors=errors) + return self.create_response(data.to_dict(), errors=errors) diff --git a/notifiers/providers/join.py b/notifiers/providers/join.py index 42a87084..d507ddc2 100644 --- a/notifiers/providers/join.py +++ b/notifiers/providers/join.py @@ -180,9 +180,9 @@ class JoinMixin: base_url = "https://joinjoaomgcd.appspot.com/_ah/api/messaging/v1" @staticmethod - def _join_request(url: str, data: JoinBaseSchema) -> tuple: + def _join_request(url: str, data: dict) -> tuple: # Can 't use generic requests util since API doesn't always return error status - params = data.to_dict() + params = data errors = None try: response = requests.get(url, params=params) @@ -213,7 +213,7 @@ class JoinDevices(JoinMixin, ProviderResource): def _get_resource(self, data: JoinBaseSchema): url = urljoin(self.base_url, self.devices_url) - response, errors = self._join_request(url, data) + response, errors = self._join_request(url, data.to_dict()) if errors: raise ResourceError( errors=errors, @@ -237,5 +237,6 @@ class Join(JoinMixin, Provider): def _send_notification(self, data: JoinSchema) -> Response: # Can 't use generic requests util since API doesn't always return error status url = urljoin(self.base_url, self.push_url) - response, errors = self._join_request(url, data) - return self.create_response(data.dict(), response, errors) + payload = data.to_dict() + response, errors = self._join_request(url, payload) + return self.create_response(payload, response, errors) diff --git a/notifiers/providers/pagerduty.py b/notifiers/providers/pagerduty.py index 5c05fa66..ff0a6a17 100644 --- a/notifiers/providers/pagerduty.py +++ b/notifiers/providers/pagerduty.py @@ -122,8 +122,8 @@ class PagerDuty(Provider): schema_model = PagerDutySchema def _send_notification(self, data: PagerDutySchema) -> Response: - url = self.base_url + payload = data.to_dict() response, errors = requests.post( - url, json=data.to_dict(), path_to_errors=self.path_to_errors + self.base_url, json=payload, path_to_errors=self.path_to_errors ) - return self.create_response(data, response, errors) + return self.create_response(payload, response, errors) diff --git a/notifiers/providers/popcornnotify.py b/notifiers/providers/popcornnotify.py index 7cf3004b..356fa642 100644 --- a/notifiers/providers/popcornnotify.py +++ b/notifiers/providers/popcornnotify.py @@ -35,8 +35,8 @@ class PopcornNotify(Provider): schema_model = PopcornNotifySchema def _send_notification(self, data: PopcornNotifySchema) -> Response: - data = data.to_dict() + payload = data.to_dict() response, errors = requests.post( - url=self.base_url, json=data, path_to_errors=self.path_to_errors + url=self.base_url, json=payload, path_to_errors=self.path_to_errors ) - return self.create_response(data, response, errors) + return self.create_response(payload, response, errors) diff --git a/notifiers/providers/pushbullet.py b/notifiers/providers/pushbullet.py index 6a056cc8..4be7a913 100644 --- a/notifiers/providers/pushbullet.py +++ b/notifiers/providers/pushbullet.py @@ -25,6 +25,10 @@ class PushbulletType(str, Enum): class PushbulletBaseSchema(ResourceSchema): token: str = Field(..., description="API access token") + @property + def auth_headers(self): + return {"Access-Token": self.token} + class PushbulletSchema(PushbulletBaseSchema): type: PushbulletType = Field(PushbulletType.note, description="Type of the push") @@ -85,23 +89,19 @@ class PushbulletMixin: name = "pushbullet" path_to_errors = "error", "message" - def _get_headers(self, token: str) -> dict: - return {"Access-Token": token} - class PushbulletDevices(PushbulletMixin, ProviderResource): """Return a list of Pushbullet devices associated to a token""" resource_name = "devices" devices_url = "https://api.pushbullet.com/v2/devices" - - _required = {"required": ["token"]} schema_model = PushbulletBaseSchema def _get_resource(self, data: PushbulletBaseSchema) -> list: - headers = self._get_headers(data.token) response, errors = requests.get( - self.devices_url, headers=headers, path_to_errors=self.path_to_errors + self.devices_url, + headers=data.auth_headers, + path_to_errors=self.path_to_errors, ) if errors: raise ResourceError( @@ -159,14 +159,13 @@ def _upload_file(self, file: FilePath, headers: dict) -> dict: return file_data def _send_notification(self, data: PushbulletSchema) -> Response: - request_data = data.to_dict() - headers = self._get_headers(request_data.pop("token")) + payload = data.to_dict() if data.file: - request_data.update(self._upload_file(data.file, headers)) + payload.update(self._upload_file(data.file, data.auth_headers)) response, errors = requests.post( self.base_url, - json=request_data, - headers=headers, + json=payload, + headers=data.auth_headers, path_to_errors=self.path_to_errors, ) - return self.create_response(request_data, response, errors) + return self.create_response(payload, response, errors) diff --git a/notifiers/providers/pushover.py b/notifiers/providers/pushover.py index 53b44482..47e2a29b 100644 --- a/notifiers/providers/pushover.py +++ b/notifiers/providers/pushover.py @@ -47,6 +47,7 @@ class PushoverBaseSchema(ResourceSchema): class PushoverSchema(PushoverBaseSchema): + _values_to_exclude = ("attachment",) user: PushoverBaseSchema.one_or_more_of(str) = Field( ..., description="The user/group key (not e-mail address) of your user (or you)" ) @@ -187,15 +188,16 @@ class Pushover(PushoverMixin, Provider): schema_model = PushoverSchema - def _send_notification(self, data: dict) -> Response: + def _send_notification(self, data: PushoverSchema) -> Response: url = urljoin(self.base_url, self.message_url) files = [] - if data.get("attachment"): - files = requests.file_list_for_request(data["attachment"], "attachment") + if data.attachment: + files = requests.file_list_for_request(data.attachment, "attachment") + payload = data.to_dict() response, errors = requests.post( - url, data=data, files=files, path_to_errors=self.path_to_errors + url, data=payload, files=files, path_to_errors=self.path_to_errors ) - return self.create_response(data, response, errors) + return self.create_response(payload, response, errors) @property def metadata(self) -> dict: From 1ef40792de47b8275025c964de6096fedd53e648 Mon Sep 17 00:00:00 2001 From: liiight Date: Tue, 10 Mar 2020 11:13:48 +0200 Subject: [PATCH 091/137] added provider metadata test --- notifiers/providers/pushover.py | 6 ------ tests/providers/test_generic_provider_tests.py | 13 +++++++++++++ 2 files changed, 13 insertions(+), 6 deletions(-) create mode 100644 tests/providers/test_generic_provider_tests.py diff --git a/notifiers/providers/pushover.py b/notifiers/providers/pushover.py index 47e2a29b..a4826c3a 100644 --- a/notifiers/providers/pushover.py +++ b/notifiers/providers/pushover.py @@ -198,9 +198,3 @@ def _send_notification(self, data: PushoverSchema) -> Response: url, data=payload, files=files, path_to_errors=self.path_to_errors ) return self.create_response(payload, response, errors) - - @property - def metadata(self) -> dict: - m = super().metadata - m["message_url"] = self.message_url - return m diff --git a/tests/providers/test_generic_provider_tests.py b/tests/providers/test_generic_provider_tests.py new file mode 100644 index 00000000..7f86fe7e --- /dev/null +++ b/tests/providers/test_generic_provider_tests.py @@ -0,0 +1,13 @@ +import pytest + +from notifiers.core import _all_providers + + +@pytest.mark.parametrize("provider", _all_providers.values()) +def test_provider_metadata(provider): + provider = provider() + assert provider.metadata == { + "base_url": provider.base_url, + "site_url": provider.site_url, + "name": provider.name, + } From d5d592e578498025d99c5ebc687e64440907f2da Mon Sep 17 00:00:00 2001 From: liiight Date: Tue, 10 Mar 2020 11:17:07 +0200 Subject: [PATCH 092/137] removed specific metadata tests --- tests/providers/test_gitter.py | 8 -------- tests/providers/test_gmail.py | 7 ------- tests/providers/test_join.py | 7 ------- tests/providers/test_mailgun.py | 7 ------- tests/providers/test_pagerduty.py | 7 ------- tests/providers/test_popcornnotify.py | 7 ------- tests/providers/test_pushbullet.py | 7 ------- tests/providers/test_pushover.py | 8 -------- tests/providers/test_simplepush.py | 7 ------- tests/providers/test_slack.py | 7 ------- tests/providers/test_smtp.py | 7 ------- tests/providers/test_statuspage.py | 7 ------- tests/providers/test_telegram.py | 7 ------- tests/providers/test_twilio.py | 7 ------- tests/providers/test_zulip.py | 7 ------- 15 files changed, 107 deletions(-) diff --git a/tests/providers/test_gitter.py b/tests/providers/test_gitter.py index 9151a012..e29b1c50 100644 --- a/tests/providers/test_gitter.py +++ b/tests/providers/test_gitter.py @@ -8,14 +8,6 @@ class TestGitter: - def test_metadata(self, provider): - assert provider.metadata == { - "base_url": "https://api.gitter.im/v1/rooms", - "message_url": "/{room_id}/chatMessages", - "name": "gitter", - "site_url": "https://gitter.im", - } - @pytest.mark.parametrize( "data, message", [ diff --git a/tests/providers/test_gmail.py b/tests/providers/test_gmail.py index 15a26599..680f3305 100644 --- a/tests/providers/test_gmail.py +++ b/tests/providers/test_gmail.py @@ -9,13 +9,6 @@ class TestGmail: """Gmail tests""" - def test_gmail_metadata(self, provider): - assert provider.metadata == { - "base_url": "smtp.gmail.com", - "name": "gmail", - "site_url": "https://www.google.com/gmail/about/", - } - @pytest.mark.parametrize( "data, message", [ diff --git a/tests/providers/test_join.py b/tests/providers/test_join.py index 00e888d2..9f7d91bd 100644 --- a/tests/providers/test_join.py +++ b/tests/providers/test_join.py @@ -8,13 +8,6 @@ class TestJoin: - def test_metadata(self, provider): - assert provider.metadata == { - "base_url": "https://joinjoaomgcd.appspot.com/_ah/api/messaging/v1", - "name": "join", - "site_url": "https://joaoapps.com/join/api/", - } - @pytest.mark.parametrize( "data, message", [({}, "apikey"), ({"apikey": "foo"}, "message")] ) diff --git a/tests/providers/test_mailgun.py b/tests/providers/test_mailgun.py index 591ee17f..bcf40b02 100644 --- a/tests/providers/test_mailgun.py +++ b/tests/providers/test_mailgun.py @@ -9,13 +9,6 @@ class TestMailgun: - def test_mailgun_metadata(self, provider): - assert provider.metadata == { - "base_url": "https://api.mailgun.net/v3/{domain}/messages", - "name": "mailgun", - "site_url": "https://documentation.mailgun.com/", - } - @pytest.mark.parametrize( "data, message", [ diff --git a/tests/providers/test_pagerduty.py b/tests/providers/test_pagerduty.py index 980fed64..ee38e305 100644 --- a/tests/providers/test_pagerduty.py +++ b/tests/providers/test_pagerduty.py @@ -8,13 +8,6 @@ class TestPagerDuty: - def test_pagerduty_metadata(self, provider): - assert provider.metadata == { - "base_url": "https://events.pagerduty.com/v2/enqueue", - "name": "pagerduty", - "site_url": "https://v2.developer.pagerduty.com/", - } - @pytest.mark.parametrize( "data, message", [ diff --git a/tests/providers/test_popcornnotify.py b/tests/providers/test_popcornnotify.py index c0085629..6f615755 100644 --- a/tests/providers/test_popcornnotify.py +++ b/tests/providers/test_popcornnotify.py @@ -8,13 +8,6 @@ class TestPopcornNotify: - def test_popcornnotify_metadata(self, provider): - assert provider.metadata == { - "base_url": "https://popcornnotify.com/notify", - "name": "popcornnotify", - "site_url": "https://popcornnotify.com/", - } - @pytest.mark.parametrize( "data, message", [ diff --git a/tests/providers/test_pushbullet.py b/tests/providers/test_pushbullet.py index d8c02720..705785cd 100644 --- a/tests/providers/test_pushbullet.py +++ b/tests/providers/test_pushbullet.py @@ -9,13 +9,6 @@ @pytest.mark.skip(reason="Re-enable once account is activated again") class TestPushbullet: - def test_metadata(self, provider): - assert provider.metadata == { - "base_url": "https://api.pushbullet.com/v2/pushes", - "name": "pushbullet", - "site_url": "https://www.pushbullet.com", - } - @pytest.mark.parametrize( "data, message", [({}, "message"), ({"message": "foo"}, "token")] ) diff --git a/tests/providers/test_pushover.py b/tests/providers/test_pushover.py index 7be5844b..51e27081 100644 --- a/tests/providers/test_pushover.py +++ b/tests/providers/test_pushover.py @@ -12,14 +12,6 @@ class TestPushover: Note: These tests assume correct environs set for NOTIFIERS_PUSHOVER_TOKEN and NOTIFIERS_PUSHOVER_USER """ - def test_pushover_metadata(self, provider): - assert provider.metadata == { - "base_url": "https://api.pushover.net/1/", - "site_url": "https://pushover.net/", - "name": "pushover", - "message_url": "messages.json", - } - @pytest.mark.parametrize( "data, message", [ diff --git a/tests/providers/test_simplepush.py b/tests/providers/test_simplepush.py index 733772a1..0e26d8c4 100644 --- a/tests/providers/test_simplepush.py +++ b/tests/providers/test_simplepush.py @@ -11,13 +11,6 @@ class TestSimplePush: Note: These tests assume correct environs set for NOTIFIERS_SIMPLEPUSH_KEY """ - def test_simplepush_metadata(self, provider): - assert provider.metadata == { - "base_url": "https://api.simplepush.io/send", - "site_url": "https://simplepush.io/", - "name": "simplepush", - } - @pytest.mark.parametrize( "data, message", [({}, "key"), ({"key": "foo"}, "message")] ) diff --git a/tests/providers/test_slack.py b/tests/providers/test_slack.py index c4f4faeb..5c81b742 100644 --- a/tests/providers/test_slack.py +++ b/tests/providers/test_slack.py @@ -10,13 +10,6 @@ class TestSlack: Online test rely on setting the env variable NOTIFIERS_SLACK_WEBHOOK_URL """ - def test_slack_metadata(self, provider): - assert provider.metadata == { - "base_url": "https://hooks.slack.com/services/", - "name": "slack", - "site_url": "https://api.slack.com/incoming-webhooks", - } - @pytest.mark.online def test_sanity(self, provider, test_message): data = {"message": test_message} diff --git a/tests/providers/test_smtp.py b/tests/providers/test_smtp.py index e00ce1f7..b97e1bd9 100644 --- a/tests/providers/test_smtp.py +++ b/tests/providers/test_smtp.py @@ -11,13 +11,6 @@ class TestSMTP(object): """SMTP tests""" - def test_smtp_metadata(self, provider): - assert provider.metadata == { - "base_url": None, - "name": "email", - "site_url": "https://en.wikipedia.org/wiki/Email", - } - @pytest.mark.parametrize( "data, message", [({}, "message"), ({"message": "foo"}, "to")] ) diff --git a/tests/providers/test_statuspage.py b/tests/providers/test_statuspage.py index e8ed2310..54f6b9a3 100644 --- a/tests/providers/test_statuspage.py +++ b/tests/providers/test_statuspage.py @@ -34,13 +34,6 @@ def close_all_open_incidents(request): class TestStatusPage: - def test_metadata(self, provider): - assert provider.metadata == { - "base_url": "https://api.statuspage.io/v1//pages/{page_id}/", - "name": "statuspage", - "site_url": "https://statuspage.io", - } - @pytest.mark.parametrize( "data, message", [ diff --git a/tests/providers/test_telegram.py b/tests/providers/test_telegram.py index ada124cd..f0a4a5e5 100644 --- a/tests/providers/test_telegram.py +++ b/tests/providers/test_telegram.py @@ -12,13 +12,6 @@ class TestTelegram: """Telegram related tests""" - def test_metadata(self, provider): - assert provider.metadata == { - "base_url": "https://api.telegram.org/bot{token}", - "name": "telegram", - "site_url": "https://core.telegram.org/", - } - @pytest.mark.parametrize( "data, message", [ diff --git a/tests/providers/test_twilio.py b/tests/providers/test_twilio.py index b0656ca2..fac47149 100644 --- a/tests/providers/test_twilio.py +++ b/tests/providers/test_twilio.py @@ -4,13 +4,6 @@ class TestTwilio: - def test_twilio_metadata(self, provider): - assert provider.metadata == { - "base_url": "https://api.twilio.com/2010-04-01/Accounts/{}/Messages.json", - "name": "twilio", - "site_url": "https://www.twilio.com/", - } - @pytest.mark.online def test_sanity(self, provider, test_message): data = {"message": test_message} diff --git a/tests/providers/test_zulip.py b/tests/providers/test_zulip.py index 32feecc4..4e39106d 100644 --- a/tests/providers/test_zulip.py +++ b/tests/providers/test_zulip.py @@ -9,13 +9,6 @@ class TestZulip: - def test_metadata(self, provider): - assert provider.metadata == { - "base_url": "https://{domain}.zulipchat.com", - "site_url": "https://zulipchat.com/api/", - "name": "zulip", - } - @pytest.mark.parametrize( "data, message", [ From 2ad0fdabe4c6370c8518ab1c8c35fa3dc42a6867 Mon Sep 17 00:00:00 2001 From: liiight Date: Tue, 10 Mar 2020 11:28:00 +0200 Subject: [PATCH 093/137] fixed pushover issue --- notifiers/providers/pushover.py | 2 +- tests/providers/test_generic_provider_tests.py | 15 ++++++++------- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/notifiers/providers/pushover.py b/notifiers/providers/pushover.py index a4826c3a..63fa80bf 100644 --- a/notifiers/providers/pushover.py +++ b/notifiers/providers/pushover.py @@ -122,7 +122,7 @@ def to_timestamp(cls, v: datetime): @root_validator def html_or_monospace(cls, values): - if all(value in values for value in ("html", "monospace")): + if all(values.get(value) for value in ("html", "monospace")): raise ValueError("Cannot use both 'html' and 'monospace'") return values diff --git a/tests/providers/test_generic_provider_tests.py b/tests/providers/test_generic_provider_tests.py index 7f86fe7e..a5441456 100644 --- a/tests/providers/test_generic_provider_tests.py +++ b/tests/providers/test_generic_provider_tests.py @@ -4,10 +4,11 @@ @pytest.mark.parametrize("provider", _all_providers.values()) -def test_provider_metadata(provider): - provider = provider() - assert provider.metadata == { - "base_url": provider.base_url, - "site_url": provider.site_url, - "name": provider.name, - } +class TestProviders: + def test_provider_metadata(self, provider): + provider = provider() + assert provider.metadata == { + "base_url": provider.base_url, + "site_url": provider.site_url, + "name": provider.name, + } From c1f3dfe0ffbd89cb0e86e2e411dda72bdd9f536e Mon Sep 17 00:00:00 2001 From: liiight Date: Tue, 10 Mar 2020 11:35:14 +0200 Subject: [PATCH 094/137] adding more tests --- notifiers/models/resource.py | 5 ++++- notifiers/providers/zulip.py | 2 ++ tests/providers/test_generic_provider_tests.py | 10 ++++++++++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/notifiers/models/resource.py b/notifiers/models/resource.py index 797144dd..cbe050a4 100644 --- a/notifiers/models/resource.py +++ b/notifiers/models/resource.py @@ -19,7 +19,10 @@ class Resource(ABC): """Base class that represent an object holding a schema and its utility methods""" - schema_model: ResourceSchema + @property + @abstractmethod + def schema_model(self) -> ResourceSchema: + pass @property @abstractmethod diff --git a/notifiers/providers/zulip.py b/notifiers/providers/zulip.py index 390559de..f6f49da0 100644 --- a/notifiers/providers/zulip.py +++ b/notifiers/providers/zulip.py @@ -85,6 +85,8 @@ class Zulip(Provider): site_url = "https://zulipchat.com/api/" path_to_errors = ("msg",) + schema_model = ZulipSchema + def _send_notification(self, data: ZulipSchema) -> Response: auth = data.email, data.api_key payload = data.to_dict() diff --git a/tests/providers/test_generic_provider_tests.py b/tests/providers/test_generic_provider_tests.py index a5441456..b22befab 100644 --- a/tests/providers/test_generic_provider_tests.py +++ b/tests/providers/test_generic_provider_tests.py @@ -1,6 +1,7 @@ import pytest from notifiers.core import _all_providers +from notifiers.exceptions import BadArguments @pytest.mark.parametrize("provider", _all_providers.values()) @@ -12,3 +13,12 @@ def test_provider_metadata(self, provider): "site_url": provider.site_url, "name": provider.name, } + + def test_missing_required(self, provider, subtests): + provider = provider() + data = {} + for arg in provider.required: + with subtests.test(msg=f"testing arg {arg}", arg=arg): + with pytest.raises(BadArguments): + provider.notify(**data) + data[arg] = "foo" From 7eaf4f61e722323303b66db48068ad794fd70223 Mon Sep 17 00:00:00 2001 From: liiight Date: Tue, 10 Mar 2020 15:20:39 +0200 Subject: [PATCH 095/137] removed missing required test --- notifiers/models/resource.py | 2 +- tests/providers/test_generic_provider_tests.py | 10 ---------- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/notifiers/models/resource.py b/notifiers/models/resource.py index cbe050a4..643927f6 100644 --- a/notifiers/models/resource.py +++ b/notifiers/models/resource.py @@ -102,7 +102,7 @@ class Provider(Resource, ABC): _resources = {} def __repr__(self): - return f"" + return f"" def __getattr__(self, item): if item in self._resources: diff --git a/tests/providers/test_generic_provider_tests.py b/tests/providers/test_generic_provider_tests.py index b22befab..a5441456 100644 --- a/tests/providers/test_generic_provider_tests.py +++ b/tests/providers/test_generic_provider_tests.py @@ -1,7 +1,6 @@ import pytest from notifiers.core import _all_providers -from notifiers.exceptions import BadArguments @pytest.mark.parametrize("provider", _all_providers.values()) @@ -13,12 +12,3 @@ def test_provider_metadata(self, provider): "site_url": provider.site_url, "name": provider.name, } - - def test_missing_required(self, provider, subtests): - provider = provider() - data = {} - for arg in provider.required: - with subtests.test(msg=f"testing arg {arg}", arg=arg): - with pytest.raises(BadArguments): - provider.notify(**data) - data[arg] = "foo" From d96c46ac5d03761b96bf25aa8b339360294214f5 Mon Sep 17 00:00:00 2001 From: liiight Date: Tue, 10 Mar 2020 15:27:24 +0200 Subject: [PATCH 096/137] fixed popcornnotify tests --- tests/providers/test_popcornnotify.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/providers/test_popcornnotify.py b/tests/providers/test_popcornnotify.py index 6f615755..f6396acb 100644 --- a/tests/providers/test_popcornnotify.py +++ b/tests/providers/test_popcornnotify.py @@ -18,9 +18,8 @@ class TestPopcornNotify: ) def test_popcornnotify_missing_required(self, data, message, provider): data["env_prefix"] = "test" - with pytest.raises(BadArguments) as e: + with pytest.raises(BadArguments, match=f"{message}\n field required"): provider.notify(**data) - assert f"'{message}' is a required property" in e.value.message @pytest.mark.online @pytest.mark.skip("Seems like service is down?") From 2a5f14ede41b90e8ea57a82506e3e0497566802f Mon Sep 17 00:00:00 2001 From: liiight Date: Wed, 11 Mar 2020 11:14:33 +0200 Subject: [PATCH 097/137] updated some tests --- tests/providers/test_gitter.py | 2 +- tests/test_core.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/providers/test_gitter.py b/tests/providers/test_gitter.py index e29b1c50..cc260fb3 100644 --- a/tests/providers/test_gitter.py +++ b/tests/providers/test_gitter.py @@ -11,7 +11,7 @@ class TestGitter: @pytest.mark.parametrize( "data, message", [ - ({}, "message\n field required"), + ({}, "text\n field required"), ({"message": "foo"}, "token\n field required"), ({"message": "foo", "token": "bar"}, "room_id\n field required"), ], diff --git a/tests/test_core.py b/tests/test_core.py index dabe887b..76ea0174 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -49,7 +49,7 @@ def test_sanity(self, mock_provider): repr(rsp) == f"" ) - assert repr(mock_provider) == "" + assert repr(mock_provider) == "" @pytest.mark.parametrize( "data", From c6f2b1a7ce3c14ead44817b65e39d7957977f4c9 Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 12 Nov 2020 00:11:50 +0200 Subject: [PATCH 098/137] fixed some tests --- notifiers/providers/gitter.py | 4 +--- notifiers/providers/pushover.py | 4 ++-- notifiers/utils/helpers.py | 1 + tests/providers/test_gitter.py | 5 ++--- tests/test_logger.py | 14 ++++++++++++-- 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/notifiers/providers/gitter.py b/notifiers/providers/gitter.py index 48b35366..05c46cdd 100644 --- a/notifiers/providers/gitter.py +++ b/notifiers/providers/gitter.py @@ -1,5 +1,3 @@ -from urllib.parse import urljoin - from pydantic import Field from ..exceptions import ResourceError @@ -81,7 +79,7 @@ class Gitter(GitterMixin, Provider): _resources = {"rooms": GitterRooms()} def _send_notification(self, data: GitterSchema) -> Response: - url = urljoin(self.base_url, f"/{data.room_id}/chatMessages") + url = f"{self.base_url}/{data.room_id}/chatMessages" payload = data.to_dict(include={"message", "status"}) response, errors = requests.post( diff --git a/notifiers/providers/pushover.py b/notifiers/providers/pushover.py index 63fa80bf..c75a52bc 100644 --- a/notifiers/providers/pushover.py +++ b/notifiers/providers/pushover.py @@ -96,12 +96,12 @@ class PushoverSchema(PushoverBaseSchema): expire: conint(le=10800) = Field( None, description="Specifies how many seconds your notification will continue to be retried for " - "(every retry seconds). requires setting priorty to 2", + "(every retry seconds). requires setting priority to 2", ) callback: HttpUrl = Field( None, description="A publicly-accessible URL that our servers will send a request to when the user has" - " acknowledged your notification. requires setting priorty to 2", + " acknowledged your notification. requires setting priority to 2", ) tags: PushoverBaseSchema.one_or_more_of(str) = Field( None, diff --git a/notifiers/utils/helpers.py b/notifiers/utils/helpers.py index c4c045e1..88d56c30 100644 --- a/notifiers/utils/helpers.py +++ b/notifiers/utils/helpers.py @@ -42,6 +42,7 @@ def dict_from_environs(prefix: str, name: str, args: list) -> dict: :param args: List of args to iterate over :return: A dict of found environ values """ + # todo consider changing via the environs lib log.debug("starting to collect environs using prefix: '%s'", prefix) return { arg: os.environ.get(f"{prefix}{name}_{arg}".upper()) diff --git a/tests/providers/test_gitter.py b/tests/providers/test_gitter.py index cc260fb3..f12c18aa 100644 --- a/tests/providers/test_gitter.py +++ b/tests/providers/test_gitter.py @@ -22,10 +22,9 @@ def test_missing_required(self, provider, data, message): provider.notify(**data) def test_bad_request(self, provider): - data = {"token": "foo", "room_id": "baz", "message": "bar"} + data = {"token": "foo", "message": "bar"} with pytest.raises(NotificationError) as e: - rsp = provider.notify(**data) - rsp.raise_on_errors() + provider.notify(**data, raise_on_errors=True) assert "Unauthorized" in e.value.message @pytest.mark.online diff --git a/tests/test_logger.py b/tests/test_logger.py index c529445a..38f5bd8a 100644 --- a/tests/test_logger.py +++ b/tests/test_logger.py @@ -53,7 +53,12 @@ def test_with_fallback(self, magic_mock_provider, handler): log.info("test") magic_mock_provider.notify.assert_called_with( - message="Could not log msg to provider 'pushover'!\nError with sent data: 'user' is a required property" + message="Could not log msg to provider 'pushover'!\n" + "Error with sent data: 2 validation errors for PushoverSchema\n" + "token\n" + " field required (type=value_error.missing)\n" + "user\n" + " field required (type=value_error.missing)" ) def test_with_fallback_with_defaults(self, magic_mock_provider, handler): @@ -71,5 +76,10 @@ def test_with_fallback_with_defaults(self, magic_mock_provider, handler): magic_mock_provider.notify.assert_called_with( foo="bar", - message="Could not log msg to provider 'pushover'!\nError with sent data: 'user' is a required property", + message="Could not log msg to provider 'pushover'!\n" + "Error with sent data: 2 validation errors for PushoverSchema\n" + "token\n" + " field required (type=value_error.missing)\n" + "user\n" + " field required (type=value_error.missing)", ) From 16c9b9b6589f9cd90bd53b769fcc38d50fa28c5e Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 12 Nov 2020 00:14:05 +0200 Subject: [PATCH 099/137] removed use of urljoin --- notifiers/providers/join.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/notifiers/providers/join.py b/notifiers/providers/join.py index d507ddc2..c3980174 100644 --- a/notifiers/providers/join.py +++ b/notifiers/providers/join.py @@ -1,7 +1,6 @@ import json from enum import Enum from typing import Union -from urllib.parse import urljoin import requests from pydantic import Extra @@ -208,11 +207,11 @@ class JoinDevices(JoinMixin, ProviderResource): """Return a list of Join devices IDs""" resource_name = "devices" - devices_url = "/listDevices" + devices_url = "listDevices" schema_model = JoinBaseSchema def _get_resource(self, data: JoinBaseSchema): - url = urljoin(self.base_url, self.devices_url) + url = f"{self.base_url}/{self.devices_url}" response, errors = self._join_request(url, data.to_dict()) if errors: raise ResourceError( @@ -228,7 +227,7 @@ def _get_resource(self, data: JoinBaseSchema): class Join(JoinMixin, Provider): """Send Join notifications""" - push_url = "/sendPush" + push_url = "sendPush" site_url = "https://joaoapps.com/join/api/" _resources = {"devices": JoinDevices()} @@ -236,7 +235,7 @@ class Join(JoinMixin, Provider): def _send_notification(self, data: JoinSchema) -> Response: # Can 't use generic requests util since API doesn't always return error status - url = urljoin(self.base_url, self.push_url) + url = f"{self.base_url}/{self.push_url}" payload = data.to_dict() response, errors = self._join_request(url, payload) return self.create_response(payload, response, errors) From ec108990ddc520f0753bfe53d80a0e9ab3c6547a Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 12 Nov 2020 00:16:08 +0200 Subject: [PATCH 100/137] set skip in cli runner to remove noise until cli is fixed --- tests/conftest.py | 1 + tests/test_cli.py | 1 - 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index efc5b617..b5b2d976 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -106,6 +106,7 @@ def resource(request, provider): @pytest.fixture def cli_runner(monkeypatch): + pytest.skip("Need to fix CLI") from notifiers_cli.core import notifiers_cli, provider_group_factory monkeypatch.setenv("LC_ALL", "en_US.utf-8") diff --git a/tests/test_cli.py b/tests/test_cli.py index 5a1f27b3..10998637 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -8,7 +8,6 @@ @pytest.mark.usefixtures("mock_provider") -@pytest.mark.skip("Need to fix CLI") class TestCLI: """CLI tests""" From b0fca461b38b37d2c0a7ade0840340ac134579f8 Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 12 Nov 2020 00:19:23 +0200 Subject: [PATCH 101/137] fixed join test --- tests/providers/test_join.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/providers/test_join.py b/tests/providers/test_join.py index 9f7d91bd..207be9aa 100644 --- a/tests/providers/test_join.py +++ b/tests/providers/test_join.py @@ -9,13 +9,13 @@ class TestJoin: @pytest.mark.parametrize( - "data, message", [({}, "apikey"), ({"apikey": "foo"}, "message")] + "data, field", [({}, "apikey"), ({"apikey": "foo"}, "text")] ) - def test_missing_required(self, data, message, provider): + def test_missing_required(self, data, field, provider): data["env_prefix"] = "test" with pytest.raises(BadArguments) as e: provider.notify(**data) - assert f"'{message}' is a required property" in e.value.message + assert f"{field}\n field required" in e.value.message @pytest.mark.skip("tests fail due to no device connected") @pytest.mark.online From 8a4dd3d3f236f40f686ccccc90f60d1d7a55785c Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 12 Nov 2020 00:22:44 +0200 Subject: [PATCH 102/137] change provider fixture class to module --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index b5b2d976..83e7c2d4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -82,7 +82,7 @@ def mock_provider(): return MockProvider() -@pytest.fixture(scope="class") +@pytest.fixture(scope="module") def provider(request): name = getattr(request.module, "provider", None) if not name: From 8671e0045938ddd07ec76aa31fd74db08e5d0ccf Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 12 Nov 2020 00:45:05 +0200 Subject: [PATCH 103/137] removed all schema required checks as they are redundant --- notifiers/models/schema.py | 2 ++ notifiers/providers/pagerduty.py | 5 --- tests/providers/test_gitter.py | 13 ------- tests/providers/test_gmail.py | 13 ------- tests/providers/test_join.py | 21 ++++++----- tests/providers/test_mailgun.py | 25 ------------- tests/providers/test_pagerduty.py | 51 ++++++--------------------- tests/providers/test_popcornnotify.py | 14 -------- tests/providers/test_pushbullet.py | 11 ------ tests/providers/test_pushover.py | 14 -------- tests/providers/test_simplepush.py | 11 ------ tests/providers/test_smtp.py | 10 ------ tests/providers/test_telegram.py | 14 -------- tests/providers/test_zulip.py | 27 -------------- 14 files changed, 23 insertions(+), 208 deletions(-) diff --git a/notifiers/models/schema.py b/notifiers/models/schema.py index 3386ef28..ae61bb0b 100644 --- a/notifiers/models/schema.py +++ b/notifiers/models/schema.py @@ -6,6 +6,7 @@ from pydantic import BaseModel from pydantic import Extra +from pydantic import NameEmail class ResourceSchema(BaseModel): @@ -60,3 +61,4 @@ def to_dict( class Config: allow_population_by_field_name = True extra = Extra.forbid + json_encoders = {NameEmail: lambda e: str(e)} diff --git a/notifiers/providers/pagerduty.py b/notifiers/providers/pagerduty.py index ff0a6a17..877a3556 100644 --- a/notifiers/providers/pagerduty.py +++ b/notifiers/providers/pagerduty.py @@ -5,7 +5,6 @@ from pydantic import constr from pydantic import Field from pydantic import HttpUrl -from pydantic import validator from ..models.resource import Provider from ..models.response import Response @@ -85,10 +84,6 @@ class PagerDutyPayload(ResourceSchema): None, description="Additional details about the event and affected system" ) - @validator("timestamp") - def to_timestamp(cls, v: datetime): - return v.timestamp() - class Config: json_encoders = {PagerDutyPayloadSeverity: lambda v: v.value} allow_population_by_field_name = True diff --git a/tests/providers/test_gitter.py b/tests/providers/test_gitter.py index f12c18aa..aa12a7f7 100644 --- a/tests/providers/test_gitter.py +++ b/tests/providers/test_gitter.py @@ -8,19 +8,6 @@ class TestGitter: - @pytest.mark.parametrize( - "data, message", - [ - ({}, "text\n field required"), - ({"message": "foo"}, "token\n field required"), - ({"message": "foo", "token": "bar"}, "room_id\n field required"), - ], - ) - def test_missing_required(self, provider, data, message): - data["env_prefix"] = "test" - with pytest.raises(BadArguments, match=message): - provider.notify(**data) - def test_bad_request(self, provider): data = {"token": "foo", "message": "bar"} with pytest.raises(NotificationError) as e: diff --git a/tests/providers/test_gmail.py b/tests/providers/test_gmail.py index 680f3305..0ece8115 100644 --- a/tests/providers/test_gmail.py +++ b/tests/providers/test_gmail.py @@ -1,6 +1,5 @@ import pytest -from notifiers.exceptions import BadArguments from notifiers.exceptions import NotificationError provider = "gmail" @@ -9,18 +8,6 @@ class TestGmail: """Gmail tests""" - @pytest.mark.parametrize( - "data, message", - [ - ({}, "message\n field required"), - ({"message": "foo"}, "to\n field required"), - ], - ) - def test_gmail_missing_required(self, data, message, provider): - data["env_prefix"] = "test" - with pytest.raises(BadArguments, match=message): - provider.notify(**data) - @pytest.mark.online def test_smtp_sanity(self, provider, test_message): """using Gmail SMTP""" diff --git a/tests/providers/test_join.py b/tests/providers/test_join.py index 207be9aa..8b8bc9fb 100644 --- a/tests/providers/test_join.py +++ b/tests/providers/test_join.py @@ -8,15 +8,6 @@ class TestJoin: - @pytest.mark.parametrize( - "data, field", [({}, "apikey"), ({"apikey": "foo"}, "text")] - ) - def test_missing_required(self, data, field, provider): - data["env_prefix"] = "test" - with pytest.raises(BadArguments) as e: - provider.notify(**data) - assert f"{field}\n field required" in e.value.message - @pytest.mark.skip("tests fail due to no device connected") @pytest.mark.online def test_sanity(self, provider): @@ -38,10 +29,18 @@ class TestJoinDevices: def test_join_devices_attribs(self, resource): assert resource.schema == { - "type": "object", - "properties": {"apikey": {"type": "string", "title": "user API key"}}, "additionalProperties": False, + "description": "The base class for Schemas", + "properties": { + "apikey": { + "description": "User API key", + "title": "Apikey", + "type": "string", + } + }, "required": ["apikey"], + "title": "JoinBaseSchema", + "type": "object", } def test_join_devices_negative(self, resource): diff --git a/tests/providers/test_mailgun.py b/tests/providers/test_mailgun.py index bcf40b02..30eebb56 100644 --- a/tests/providers/test_mailgun.py +++ b/tests/providers/test_mailgun.py @@ -2,37 +2,12 @@ import pytest -from notifiers.exceptions import BadArguments from notifiers.models.response import ResponseStatus provider = "mailgun" class TestMailgun: - @pytest.mark.parametrize( - "data, message", - [ - ({}, "Either 'text' or 'html' are required"), - ({"message": "foo"}, "api_key\n field required"), - ( - {"message": "foo", "to": "non-email"}, - "to\n value is not a valid email address", - ), - ( - {"message": "foo", "to": "1@1.com", "api_key": "foo"}, - "domain\n field required", - ), - ( - {"message": "foo", "to": "1@1.com", "api_key": "foo", "domain": "foo"}, - "from\n field required", - ), - ], - ) - def test_mailgun_missing_required(self, data, message, provider): - data["env_prefix"] = "test" - with pytest.raises(BadArguments, match=message): - provider.notify(**data) - @pytest.mark.online def test_mailgun_sanity(self, provider, test_message): provider.notify(message=test_message, raise_on_errors=True) diff --git a/tests/providers/test_pagerduty.py b/tests/providers/test_pagerduty.py index ee38e305..8fceac86 100644 --- a/tests/providers/test_pagerduty.py +++ b/tests/providers/test_pagerduty.py @@ -2,46 +2,15 @@ import pytest -from notifiers.exceptions import BadArguments - provider = "pagerduty" class TestPagerDuty: - @pytest.mark.parametrize( - "data, message", - [ - ({}, "routing_key"), - ({"routing_key": "foo"}, "event_action"), - ({"routing_key": "foo", "event_action": "trigger"}, "source"), - ( - {"routing_key": "foo", "event_action": "trigger", "source": "foo"}, - "severity", - ), - ( - { - "routing_key": "foo", - "event_action": "trigger", - "source": "foo", - "severity": "info", - }, - "message", - ), - ], - ) - def test_pagerduty_missing_required(self, data, message, provider): - data["env_prefix"] = "test" - with pytest.raises(BadArguments) as e: - provider.notify(**data) - assert f"'{message}' is a required property" in e.value.message - @pytest.mark.online def test_pagerduty_sanity(self, provider, test_message): data = { - "message": test_message, "event_action": "trigger", - "source": "foo", - "severity": "info", + "payload": {"message": test_message, "source": "foo", "severity": "info"}, } rsp = provider.notify(**data, raise_on_errors=True) raw_rsp = rsp.response.json() @@ -64,15 +33,17 @@ def test_pagerduty_all_options(self, provider, test_message): } ] data = { - "message": test_message, + "payload": { + "message": test_message, + "source": "bar", + "severity": "info", + "timestamp": datetime.datetime.now(), + "component": "baz", + "group": "bla", + "class": "buzu", + "custom_details": {"foo": "bar", "boo": "yikes"}, + }, "event_action": "trigger", - "source": "bar", - "severity": "info", - "timestamp": datetime.datetime.now().isoformat(), - "component": "baz", - "group": "bla", - "class": "buzu", - "custom_details": {"foo": "bar", "boo": "yikes"}, "images": images, "links": links, } diff --git a/tests/providers/test_popcornnotify.py b/tests/providers/test_popcornnotify.py index f6396acb..def7bcf4 100644 --- a/tests/providers/test_popcornnotify.py +++ b/tests/providers/test_popcornnotify.py @@ -1,6 +1,5 @@ import pytest -from notifiers.exceptions import BadArguments from notifiers.exceptions import NotificationError from notifiers.models.response import ResponseStatus @@ -8,19 +7,6 @@ class TestPopcornNotify: - @pytest.mark.parametrize( - "data, message", - [ - ({}, "message"), - ({"message": "foo"}, "api_key"), - ({"message": "foo", "api_key": "foo"}, "recipients"), - ], - ) - def test_popcornnotify_missing_required(self, data, message, provider): - data["env_prefix"] = "test" - with pytest.raises(BadArguments, match=f"{message}\n field required"): - provider.notify(**data) - @pytest.mark.online @pytest.mark.skip("Seems like service is down?") def test_popcornnotify_sanity(self, provider, test_message): diff --git a/tests/providers/test_pushbullet.py b/tests/providers/test_pushbullet.py index 705785cd..19ad5442 100644 --- a/tests/providers/test_pushbullet.py +++ b/tests/providers/test_pushbullet.py @@ -2,22 +2,11 @@ import pytest -from notifiers.exceptions import BadArguments - provider = "pushbullet" @pytest.mark.skip(reason="Re-enable once account is activated again") class TestPushbullet: - @pytest.mark.parametrize( - "data, message", [({}, "message"), ({"message": "foo"}, "token")] - ) - def test_missing_required(self, data, message, provider): - data["env_prefix"] = "test" - with pytest.raises(BadArguments) as e: - provider.notify(**data) - assert f"'{message}' is a required property" in e.value.message - @pytest.mark.online def test_sanity(self, provider, test_message): data = {"message": test_message} diff --git a/tests/providers/test_pushover.py b/tests/providers/test_pushover.py index 51e27081..6ad53498 100644 --- a/tests/providers/test_pushover.py +++ b/tests/providers/test_pushover.py @@ -12,20 +12,6 @@ class TestPushover: Note: These tests assume correct environs set for NOTIFIERS_PUSHOVER_TOKEN and NOTIFIERS_PUSHOVER_USER """ - @pytest.mark.parametrize( - "data, message", - [ - ({}, "user"), - ({"user": "foo"}, "message"), - ({"user": "foo", "message": "bla"}, "token"), - ], - ) - def test_missing_required(self, data, message, provider): - data["env_prefix"] = "test" - with pytest.raises(BadArguments) as e: - provider.notify(**data) - assert f"'{message}' is a required property" in e.value.message - @pytest.mark.parametrize( "data, message", [({}, "expire"), ({"expire": 30}, "retry")] ) diff --git a/tests/providers/test_simplepush.py b/tests/providers/test_simplepush.py index 0e26d8c4..0152ff53 100644 --- a/tests/providers/test_simplepush.py +++ b/tests/providers/test_simplepush.py @@ -1,7 +1,5 @@ import pytest -from notifiers.exceptions import BadArguments - provider = "simplepush" @@ -11,15 +9,6 @@ class TestSimplePush: Note: These tests assume correct environs set for NOTIFIERS_SIMPLEPUSH_KEY """ - @pytest.mark.parametrize( - "data, message", [({}, "key"), ({"key": "foo"}, "message")] - ) - def test_simplepush_missing_required(self, data, message, provider): - data["env_prefix"] = "test" - with pytest.raises(BadArguments) as e: - provider.notify(**data) - assert f"'{message}' is a required property" in e.value.message - @pytest.mark.online def test_simplepush_sanity(self, provider, test_message): """Successful simplepush notification""" diff --git a/tests/providers/test_smtp.py b/tests/providers/test_smtp.py index b97e1bd9..fd7556e2 100644 --- a/tests/providers/test_smtp.py +++ b/tests/providers/test_smtp.py @@ -2,7 +2,6 @@ import pytest -from notifiers.exceptions import BadArguments from notifiers.exceptions import NotificationError provider = "email" @@ -11,15 +10,6 @@ class TestSMTP(object): """SMTP tests""" - @pytest.mark.parametrize( - "data, message", [({}, "message"), ({"message": "foo"}, "to")] - ) - def test_smtp_missing_required(self, data, message, provider): - data["env_prefix"] = "test" - with pytest.raises(BadArguments) as e: - provider.notify(**data) - assert f"'{message}' is a required property" in e.value.message - def test_smtp_no_host(self, provider): data = { "to": "foo@foo.com", diff --git a/tests/providers/test_telegram.py b/tests/providers/test_telegram.py index f0a4a5e5..5a02416b 100644 --- a/tests/providers/test_telegram.py +++ b/tests/providers/test_telegram.py @@ -12,20 +12,6 @@ class TestTelegram: """Telegram related tests""" - @pytest.mark.parametrize( - "data, message", - [ - ({}, "message"), - ({"message": "foo"}, "chat_id"), - ({"message": "foo", "chat_id": 1}, "token"), - ], - ) - def test_missing_required(self, data, message, provider): - data["env_prefix"] = "test" - with pytest.raises(BadArguments) as e: - provider.notify(**data) - assert f"'{message}' is a required property" in e.value.message - def test_bad_token(self, provider): data = {"token": "foo", "chat_id": 1, "message": "foo"} with pytest.raises(NotificationError) as e: diff --git a/tests/providers/test_zulip.py b/tests/providers/test_zulip.py index 4e39106d..9e86935b 100644 --- a/tests/providers/test_zulip.py +++ b/tests/providers/test_zulip.py @@ -2,39 +2,12 @@ import pytest -from notifiers.exceptions import BadArguments from notifiers.exceptions import NotifierException provider = "zulip" class TestZulip: - @pytest.mark.parametrize( - "data, message", - [ - ( - {"email": "foo", "api_key": "bar", "message": "boo", "to": "bla"}, - "domain", - ), - ( - { - "email": "foo", - "api_key": "bar", - "message": "boo", - "to": "bla", - "domain": "bla", - "server": "fop", - }, - "Only one of 'domain' or 'server' is allowed", - ), - ], - ) - def test_missing_required(self, data, message, provider): - data["env_prefix"] = "test" - with pytest.raises(BadArguments) as e: - provider.notify(**data) - assert message in e.value.message - @pytest.mark.online def test_sanity(self, provider, test_message): data = { From 4fd5e572ac271a64aab67ebef3ef8455da7f5a45 Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 12 Nov 2020 01:07:38 +0200 Subject: [PATCH 104/137] fixed more tests, fixed zulip logic --- notifiers/providers/pushover.py | 8 +++---- notifiers/providers/zulip.py | 38 +++++++++++++----------------- tests/providers/test_smtp.py | 22 ++++++++++------- tests/providers/test_statuspage.py | 13 ---------- tests/providers/test_telegram.py | 12 ++++++++-- 5 files changed, 43 insertions(+), 50 deletions(-) diff --git a/notifiers/providers/pushover.py b/notifiers/providers/pushover.py index c75a52bc..65784407 100644 --- a/notifiers/providers/pushover.py +++ b/notifiers/providers/pushover.py @@ -43,10 +43,14 @@ class PushoverSound(str, Enum): class PushoverBaseSchema(ResourceSchema): + """Pushover base schema""" + token: str = Field(..., description="Your application's API token ") class PushoverSchema(PushoverBaseSchema): + """Pushover schema""" + _values_to_exclude = ("attachment",) user: PushoverBaseSchema.one_or_more_of(str) = Field( ..., description="The user/group key (not e-mail address) of your user (or you)" @@ -116,10 +120,6 @@ def bool_to_num(cls, v): def to_csv(cls, v): return cls.to_comma_separated(v) - @validator("timestamp") - def to_timestamp(cls, v: datetime): - return v.timestamp() - @root_validator def html_or_monospace(cls, values): if all(values.get(value) for value in ("html", "monospace")): diff --git a/notifiers/providers/zulip.py b/notifiers/providers/zulip.py index f6f49da0..8ab2010a 100644 --- a/notifiers/providers/zulip.py +++ b/notifiers/providers/zulip.py @@ -1,13 +1,11 @@ from enum import Enum from typing import Union -from urllib.parse import urljoin from pydantic import constr from pydantic import EmailStr from pydantic import Field from pydantic import HttpUrl from pydantic import root_validator -from pydantic import ValidationError from pydantic import validator from ..models.resource import Provider @@ -16,20 +14,6 @@ from ..utils import requests -class ZulipUrl(str): - @classmethod - def __get_validators__(cls): - yield cls.validate - - @classmethod - def validate(cls, v): - try: - url = HttpUrl(v) - except ValidationError: - url = f"https://{v}.zulipchat.com" - return urljoin(url, "/api/v1/messages") - - class MessageType(str, Enum): private = "private" stream = "stream" @@ -39,10 +23,8 @@ class ZulipSchema(ResourceSchema): """Send a stream or a private message""" api_key: str = Field(..., description="User API Key") - url_or_domain: ZulipUrl = Field( - ..., - description="Either a full server URL or subdomain to be used with zulipchat.com", - ) + url: HttpUrl = Field(None, description="Server URL") + domain: str = Field(None, description="Subdomain to use with zulipchat.com") email: EmailStr = Field(..., description='"User email') type: MessageType = Field( MessageType.stream, @@ -65,13 +47,25 @@ class ZulipSchema(ResourceSchema): def csv(cls, v): return ResourceSchema.to_comma_separated(v) - _values_to_exclude = "email", "api_key", "url_or_domain" + _values_to_exclude = ( + "email", + "api_key", + "domain", + "url", + ) @root_validator def root(cls, values): if values["type"] is MessageType.stream and not values.get("topic"): raise ValueError("'topic' is required when 'type' is 'stream'") + if "domain" not in values and "url" not in values: + raise ValueError("Either 'url' or 'domain' are required") + + base_url = values.get("url", f"https://{values['domain']}.zulipchat.com") + url = f"{base_url}/api/v1/messages" + values["server_url"] = url + return values class Config: @@ -91,7 +85,7 @@ def _send_notification(self, data: ZulipSchema) -> Response: auth = data.email, data.api_key payload = data.to_dict() response, errors = requests.post( - data.url_or_domain, + data.server_url, data=payload, auth=auth, path_to_errors=self.path_to_errors, diff --git a/tests/providers/test_smtp.py b/tests/providers/test_smtp.py index fd7556e2..9e1ab95e 100644 --- a/tests/providers/test_smtp.py +++ b/tests/providers/test_smtp.py @@ -1,4 +1,5 @@ from email.message import EmailMessage +from pathlib import Path import pytest @@ -68,15 +69,18 @@ def test_attachment(self, provider, tmpdir): ) assert rsp.data["attachments"] == attachments - def test_attachment_mimetypes(self, provider, tmpdir): - dir_ = tmpdir.mkdir("sub") - file_1 = dir_.join("foo.txt") - file_1.write("foo") - file_2 = dir_.join("bar.jpg") - file_2.write("foo") - file_3 = dir_.join("baz.pdf") - file_3.write("foo") - attachments = [str(file_1), str(file_2), str(file_3)] + def test_attachment_mimetypes(self, provider, tmp_path): + smtp_base: Path = tmp_path / "smtp_base" + file_1 = smtp_base / "foo.txt" + file_1.write_text("foo") + + file_2 = smtp_base / "bar.jpg" + file_2.write_text("foo") + + file_3 = smtp_base / "baz.pdf" + file_3.write_text("foo") + + attachments = [file_1, file_2, file_3] email = EmailMessage() provider.add_attachments_to_email(attachments=attachments, email=email) attach1, attach2, attach3 = email.iter_attachments() diff --git a/tests/providers/test_statuspage.py b/tests/providers/test_statuspage.py index 54f6b9a3..7b48d305 100644 --- a/tests/providers/test_statuspage.py +++ b/tests/providers/test_statuspage.py @@ -34,19 +34,6 @@ def close_all_open_incidents(request): class TestStatusPage: - @pytest.mark.parametrize( - "data, message", - [ - ({}, "message"), - ({"message": "foo"}, "api_key"), - ({"message": "foo", "api_key": 1}, "page_id"), - ], - ) - def test_missing_required(self, data, message, provider): - data["env_prefix"] = "test" - with pytest.raises(BadArguments, match=f"'{message}' is a required property"): - provider.notify(**data) - @pytest.mark.parametrize( "added_data, message", [ diff --git a/tests/providers/test_telegram.py b/tests/providers/test_telegram.py index 5a02416b..9b55d85c 100644 --- a/tests/providers/test_telegram.py +++ b/tests/providers/test_telegram.py @@ -35,7 +35,7 @@ def test_sanity(self, provider, test_message): @pytest.mark.online def test_all_options(self, provider, test_message): data = { - "parse_mode": "markdown", + "parse_mode": "Markdown", "disable_web_page_preview": True, "disable_notification": True, "message": test_message, @@ -50,8 +50,16 @@ class TestTelegramResources: def test_telegram_updates_attribs(self, resource): assert resource.schema == { "additionalProperties": False, - "properties": {"token": {"title": "Bot token", "type": "string"}}, + "description": "The base class for Schemas", + "properties": { + "token": { + "description": "Bot token", + "title": "Token", + "type": "string", + } + }, "required": ["token"], + "title": "TelegramBaseSchema", "type": "object", } assert resource.name == provider From 9c51f7a50573dd7fb5f95fac4bbc68265496768b Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 12 Nov 2020 01:18:35 +0200 Subject: [PATCH 105/137] more fixes --- notifiers/providers/zulip.py | 7 +++++-- tests/providers/test_pushover.py | 22 ++++++++++++++++++---- tests/providers/test_smtp.py | 3 +++ tests/providers/test_telegram.py | 2 +- 4 files changed, 27 insertions(+), 7 deletions(-) diff --git a/notifiers/providers/zulip.py b/notifiers/providers/zulip.py index 8ab2010a..eaa89c5f 100644 --- a/notifiers/providers/zulip.py +++ b/notifiers/providers/zulip.py @@ -28,7 +28,7 @@ class ZulipSchema(ResourceSchema): email: EmailStr = Field(..., description='"User email') type: MessageType = Field( MessageType.stream, - description="The type of message to be sent. private for a private message and stream for a stream message", + description="The type of message to be sent. 'private' for a private message and 'stream' for a stream message", ) message: constr(max_length=10000) = Field( ..., description="The content of the message", alias="content" @@ -42,6 +42,9 @@ class ZulipSchema(ResourceSchema): None, description="The topic of the message. Only required if type is stream, ignored otherwise", ) + subject: str = Field( + None, description="Message subject. Relevant only for stream messages" + ) @validator("to", whole=True) def csv(cls, v): @@ -62,7 +65,7 @@ def root(cls, values): if "domain" not in values and "url" not in values: raise ValueError("Either 'url' or 'domain' are required") - base_url = values.get("url", f"https://{values['domain']}.zulipchat.com") + base_url = values["url"] or f"https://{values['domain']}.zulipchat.com" url = f"{base_url}/api/v1/messages" values["server_url"] = url diff --git a/tests/providers/test_pushover.py b/tests/providers/test_pushover.py index 6ad53498..972c53e4 100644 --- a/tests/providers/test_pushover.py +++ b/tests/providers/test_pushover.py @@ -77,11 +77,18 @@ class TestPushoverSoundsResource: def test_pushover_sounds_attribs(self, resource): assert resource.schema == { - "type": "object", + "additionalProperties": False, + "description": "Pushover base schema", "properties": { - "token": {"type": "string", "title": "your application's API token"} + "token": { + "description": "Your application's API token ", + "title": "Token", + "type": "string", + } }, "required": ["token"], + "title": "PushoverBaseSchema", + "type": "object", } assert resource.name == provider @@ -100,11 +107,18 @@ class TestPushoverLimitsResource: def test_pushover_limits_attribs(self, resource): assert resource.schema == { - "type": "object", + "additionalProperties": False, + "description": "Pushover base schema", "properties": { - "token": {"type": "string", "title": "your application's API token"} + "token": { + "description": "Your application's API token ", + "title": "Token", + "type": "string", + } }, "required": ["token"], + "title": "PushoverBaseSchema", + "type": "object", } assert resource.name == provider diff --git a/tests/providers/test_smtp.py b/tests/providers/test_smtp.py index 9e1ab95e..15c3b05b 100644 --- a/tests/providers/test_smtp.py +++ b/tests/providers/test_smtp.py @@ -72,12 +72,15 @@ def test_attachment(self, provider, tmpdir): def test_attachment_mimetypes(self, provider, tmp_path): smtp_base: Path = tmp_path / "smtp_base" file_1 = smtp_base / "foo.txt" + file_1.touch() file_1.write_text("foo") file_2 = smtp_base / "bar.jpg" + file_2.touch() file_2.write_text("foo") file_3 = smtp_base / "baz.pdf" + file_3.touch() file_3.write_text("foo") attachments = [file_1, file_2, file_3] diff --git a/tests/providers/test_telegram.py b/tests/providers/test_telegram.py index 9b55d85c..d93fd41b 100644 --- a/tests/providers/test_telegram.py +++ b/tests/providers/test_telegram.py @@ -63,7 +63,7 @@ def test_telegram_updates_attribs(self, resource): "type": "object", } assert resource.name == provider - assert resource.required == {"required": ["token"]} + assert resource.required == ["token"] def test_telegram_updates_negative(self, resource): with pytest.raises(BadArguments): From 501051c34a2b20c280171d1adf62547554025e08 Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 12 Nov 2020 01:23:12 +0200 Subject: [PATCH 106/137] fixed more tests --- notifiers/providers/zulip.py | 3 --- tests/providers/test_smtp.py | 5 ++--- tests/providers/test_zulip.py | 10 +++++----- 3 files changed, 7 insertions(+), 11 deletions(-) diff --git a/notifiers/providers/zulip.py b/notifiers/providers/zulip.py index eaa89c5f..4403f5a2 100644 --- a/notifiers/providers/zulip.py +++ b/notifiers/providers/zulip.py @@ -42,9 +42,6 @@ class ZulipSchema(ResourceSchema): None, description="The topic of the message. Only required if type is stream, ignored otherwise", ) - subject: str = Field( - None, description="Message subject. Relevant only for stream messages" - ) @validator("to", whole=True) def csv(cls, v): diff --git a/tests/providers/test_smtp.py b/tests/providers/test_smtp.py index 15c3b05b..cfe70701 100644 --- a/tests/providers/test_smtp.py +++ b/tests/providers/test_smtp.py @@ -71,16 +71,15 @@ def test_attachment(self, provider, tmpdir): def test_attachment_mimetypes(self, provider, tmp_path): smtp_base: Path = tmp_path / "smtp_base" + smtp_base.mkdir() + file_1 = smtp_base / "foo.txt" - file_1.touch() file_1.write_text("foo") file_2 = smtp_base / "bar.jpg" - file_2.touch() file_2.write_text("foo") file_3 = smtp_base / "baz.pdf" - file_3.touch() file_3.write_text("foo") attachments = [file_1, file_2, file_3] diff --git a/tests/providers/test_zulip.py b/tests/providers/test_zulip.py index 9e86935b..2c7ae69c 100644 --- a/tests/providers/test_zulip.py +++ b/tests/providers/test_zulip.py @@ -14,7 +14,7 @@ def test_sanity(self, provider, test_message): "to": "general", "message": test_message, "domain": "notifiers", - "subject": "test", + "topic": "test", } rsp = provider.notify(**data) rsp.raise_on_errors() @@ -35,9 +35,9 @@ def test_zulip_type_key(self, provider): api_key="bar", to="baz", domain="bla", - type_="private", + type="private", message="foo", - subject="foo", + topic="foo", ) rsp_data = rsp.data assert not rsp_data.get("type_") @@ -50,7 +50,7 @@ def test_zulip_missing_subject(self, provider): api_key="bar", to="baz@foo.com", domain="bla", - type_="stream", + type="stream", message="foo", ) - assert "'subject' is required when 'type' is 'stream'" in e.value.message + assert "'topic' is required when 'type' is 'stream'" in e.value.message From 12484d18d12c102aa8925c742135c97a8a28d6c4 Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 12 Nov 2020 15:34:46 +0200 Subject: [PATCH 107/137] added alias --- notifiers/providers/twilio.py | 1 + 1 file changed, 1 insertion(+) diff --git a/notifiers/providers/twilio.py b/notifiers/providers/twilio.py index 21e59455..9ec9d26d 100644 --- a/notifiers/providers/twilio.py +++ b/notifiers/providers/twilio.py @@ -93,6 +93,7 @@ class TwilioSchema(ResourceSchema): "that is enabled for the type of message you want to send. Phone numbers or short codes purchased " "from Twilio also work here. You cannot, for example, spoof messages from a private cell phone " "number. If you are using messaging_service_sid, this parameter must be empty", + alias="from", ) messaging_service_sid: str = Field( None, From 30a2a9a41f7ea42f7c0cdbe1930cc28d03cee553 Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 12 Nov 2020 15:43:57 +0200 Subject: [PATCH 108/137] fixed or skipped all tests --- tests/providers/test_statuspage.py | 58 ++++++++---------------------- tests/providers/test_twilio.py | 1 + 2 files changed, 15 insertions(+), 44 deletions(-) diff --git a/tests/providers/test_statuspage.py b/tests/providers/test_statuspage.py index 7b48d305..80d2ca98 100644 --- a/tests/providers/test_statuspage.py +++ b/tests/providers/test_statuspage.py @@ -34,42 +34,6 @@ def close_all_open_incidents(request): class TestStatusPage: - @pytest.mark.parametrize( - "added_data, message", - [ - ( - { - "scheduled_for": datetime.datetime.now().isoformat(), - "scheduled_until": datetime.datetime.now().isoformat(), - "backfill_date": str(datetime.datetime.now().date()), - "backfilled": True, - }, - "Cannot set both 'backfill' and 'scheduled' incident properties in the same notification!", - ), - ( - { - "scheduled_for": datetime.datetime.now().isoformat(), - "scheduled_until": datetime.datetime.now().isoformat(), - "status": "investigating", - }, - "is a realtime incident status! Please choose one of", - ), - ( - { - "backfill_date": str(datetime.datetime.now().date()), - "backfilled": True, - "status": "investigating", - }, - "Cannot set 'status' when setting 'backfill'!", - ), - ], - ) - def test_data_dependencies(self, added_data, message, provider): - data = {"api_key": "foo", "message": "foo", "page_id": "foo"} - data.update(added_data) - with pytest.raises(BadArguments, match=message): - provider.notify(**data) - def test_errors(self, provider): data = {"api_key": "foo", "page_id": "foo", "message": "foo"} rsp = provider.notify(**data) @@ -86,7 +50,6 @@ def test_errors(self, provider): "message": "Test realitme", "status": "investigating", "body": "Incident body", - "wants_twitter_update": False, "impact_override": "minor", "deliver_notifications": False, } @@ -96,7 +59,6 @@ def test_errors(self, provider): "message": "Test scheduled", "status": "scheduled", "body": "Incident body", - "wants_twitter_update": False, "impact_override": "minor", "deliver_notifications": False, "scheduled_for": ( @@ -116,9 +78,7 @@ def test_errors(self, provider): "body": "Incident body", "impact_override": "minor", "backfilled": True, - "backfill_date": ( - datetime.date.today() - datetime.timedelta(days=1) - ).isoformat(), + "backfill_date": datetime.datetime.now(), } ), ], @@ -133,16 +93,26 @@ class TestStatuspageComponents: def test_statuspage_components_attribs(self, resource): assert resource.schema == { "additionalProperties": False, + "description": "The base class for Schemas", "properties": { - "api_key": {"title": "OAuth2 token", "type": "string"}, - "page_id": {"title": "Page ID", "type": "string"}, + "api_key": { + "description": "Authentication token", + "title": "Api Key", + "type": "string", + }, + "page_id": { + "description": "Paged ID", + "title": "Page Id", + "type": "string", + }, }, "required": ["api_key", "page_id"], + "title": "StatuspageBaseSchema", "type": "object", } assert resource.name == provider - assert resource.required == {"required": ["api_key", "page_id"]} + assert resource.required == ["api_key", "page_id"] def test_statuspage_components_negative(self, resource): with pytest.raises(BadArguments): diff --git a/tests/providers/test_twilio.py b/tests/providers/test_twilio.py index fac47149..5d8726ad 100644 --- a/tests/providers/test_twilio.py +++ b/tests/providers/test_twilio.py @@ -4,6 +4,7 @@ class TestTwilio: + @pytest.mark.skip("Skip until environs are fixed") @pytest.mark.online def test_sanity(self, provider, test_message): data = {"message": test_message} From 5e270184ccd0b8ac5693bc38fb6a388baec327bd Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 12 Nov 2020 15:45:59 +0200 Subject: [PATCH 109/137] tweak --- notifiers/providers/email.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/notifiers/providers/email.py b/notifiers/providers/email.py index 16f68c41..33a8882b 100644 --- a/notifiers/providers/email.py +++ b/notifiers/providers/email.py @@ -82,12 +82,12 @@ class SMTP(Provider): @staticmethod def _get_mimetype(attachment: Path) -> Tuple[str, str]: """Taken from https://docs.python.org/3/library/email.examples.html""" - ctype, encoding = mimetypes.guess_type(str(attachment)) - if ctype is None or encoding is not None: + content_type, encoding = mimetypes.guess_type(str(attachment)) + if content_type is None or encoding is not None: # No guess could be made, or the file is encoded (compressed), so # use a generic bag-of-bits type. - ctype = "application/octet-stream" - maintype, subtype = ctype.split("/", 1) + content_type = "application/octet-stream" + maintype, subtype = content_type.split("/", 1) return maintype, subtype def __init__(self): From 681a234ec359b8c9f20cc99c0d7e8b3a6873ff57 Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 12 Nov 2020 15:50:45 +0200 Subject: [PATCH 110/137] changed logger --- tests/providers/test_statuspage.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/providers/test_statuspage.py b/tests/providers/test_statuspage.py index 80d2ca98..e5dc1078 100644 --- a/tests/providers/test_statuspage.py +++ b/tests/providers/test_statuspage.py @@ -12,7 +12,7 @@ provider = "statuspage" -log = logging.getLogger("statuspage") +log = logging.getLogger("notifiers") @pytest.fixture(autouse=True, scope="module") From b4adcdb9731f806211f88e6296bfc7a650d221fd Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 12 Nov 2020 15:51:42 +0200 Subject: [PATCH 111/137] updated test to py3.8 and py3.9 --- .github/workflows/pythonpackage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index b139596c..44b1268f 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -9,7 +9,7 @@ jobs: strategy: max-parallel: 2 matrix: - python-version: [3.6, 3.7] + python-version: [3.6, 3.7, 3.8, 3.9] steps: - uses: actions/checkout@v1 From 23459d0d225a60d55f1d7c7ac6f699359c211e97 Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 12 Nov 2020 22:19:52 +0200 Subject: [PATCH 112/137] Revert "updated test to py3.8 and py3.9" This reverts commit e4696f3a --- .github/workflows/pythonpackage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 44b1268f..b139596c 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -9,7 +9,7 @@ jobs: strategy: max-parallel: 2 matrix: - python-version: [3.6, 3.7, 3.8, 3.9] + python-version: [3.6, 3.7] steps: - uses: actions/checkout@v1 From 01d58c90dcb4cdaddf6aa774a4ecc59b12cd3444 Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 12 Nov 2020 22:57:04 +0200 Subject: [PATCH 113/137] added ResourceSchema tests --- notifiers/models/schema.py | 9 +++------ tests/test_schema.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 tests/test_schema.py diff --git a/notifiers/models/schema.py b/notifiers/models/schema.py index ae61bb0b..bac6e67a 100644 --- a/notifiers/models/schema.py +++ b/notifiers/models/schema.py @@ -16,18 +16,15 @@ class ResourceSchema(BaseModel): @staticmethod def to_list(value: Union[Any, List[Any]]) -> List[Any]: - # todo convert this to a custom type instead of a helper method """Helper method to make sure a return value is a list""" if not isinstance(value, list): return [value] return value - @staticmethod - def to_comma_separated(values: Union[Any, List[Any]]) -> str: - # todo convert this to a custom type instead of a helper method + @classmethod + def to_comma_separated(cls, values: Union[Any, List[Any]]) -> str: """Helper method that return a comma separates string from a value""" - if not isinstance(values, list): - values = [values] + values = cls.to_list(values) return ",".join(str(value) for value in values) @staticmethod diff --git a/tests/test_schema.py b/tests/test_schema.py new file mode 100644 index 00000000..88547730 --- /dev/null +++ b/tests/test_schema.py @@ -0,0 +1,35 @@ +from hypothesis import given +from hypothesis import strategies as st + +from notifiers.models.schema import ResourceSchema + +simple_type = st.one_of(st.text(), st.dates(), st.booleans()) + +given_any_object = given( + st.one_of( + simple_type, + st.dictionaries(keys=simple_type, values=simple_type), + st.lists(elements=simple_type), + ) +) + + +class TestResourceSchema: + """Test the resource schema base class""" + + @given_any_object + def test_to_list(self, any_object): + list_of_obj = [any_object] if not isinstance(any_object, list) else any_object + assert ResourceSchema.to_list(any_object) == list_of_obj + + @given_any_object + def test_to_csv(self, any_object): + list_of_obj = ResourceSchema.to_list(any_object) + assert ResourceSchema.to_comma_separated(any_object) == ",".join( + str(value) for value in list_of_obj + ) + + def test_to_dict(self, mock_provider): + data = {"required": "foo"} + mock = mock_provider.validate_data(data) + assert mock.to_dict() == {"option_with_default": "foo", "required": "foo"} From 7d229c64afcd68f83082711432f63d4ad59390a1 Mon Sep 17 00:00:00 2001 From: liiight Date: Thu, 12 Nov 2020 23:09:33 +0200 Subject: [PATCH 114/137] added types to core --- notifiers/core.py | 7 ++-- notifiers/models/resource.py | 61 +++++++++++++++++++-------------- notifiers/models/schema.py | 4 +++ notifiers/providers/__init__.py | 6 +++- 4 files changed, 48 insertions(+), 30 deletions(-) diff --git a/notifiers/core.py b/notifiers/core.py index fc1809a9..94f491d9 100644 --- a/notifiers/core.py +++ b/notifiers/core.py @@ -1,7 +1,8 @@ import logging +from typing import List from .exceptions import NoSuchNotifierError -from .models.resource import Provider +from .models.resource import T_Provider from .models.response import Response log = logging.getLogger("notifiers") @@ -10,7 +11,7 @@ from .providers import _all_providers # noqa: E402 -def get_notifier(provider_name: str, strict: bool = False) -> Provider: +def get_notifier(provider_name: str, strict: bool = False) -> T_Provider: """ Convenience method to return an instantiated :class:`~notifiers.core.Provider` object according to it ``name`` @@ -26,7 +27,7 @@ def get_notifier(provider_name: str, strict: bool = False) -> Provider: raise NoSuchNotifierError(name=provider_name) -def all_providers() -> list: +def all_providers() -> List[str]: """Returns a list of all :class:`~notifiers.core.Provider` names""" return list(_all_providers.keys()) diff --git a/notifiers/models/resource.py b/notifiers/models/resource.py index 643927f6..1d045562 100644 --- a/notifiers/models/resource.py +++ b/notifiers/models/resource.py @@ -1,7 +1,9 @@ from abc import ABC from abc import abstractmethod +from typing import Dict from typing import List from typing import Optional +from typing import TypeVar import requests from pydantic import ValidationError @@ -10,6 +12,7 @@ from notifiers.models.response import Response from notifiers.models.response import ResponseStatus from notifiers.models.schema import ResourceSchema +from notifiers.models.schema import T_ResourceSchema from notifiers.utils.helpers import dict_from_environs from notifiers.utils.helpers import merge_dicts @@ -21,7 +24,7 @@ class Resource(ABC): @property @abstractmethod - def schema_model(self) -> ResourceSchema: + def schema_model(self) -> T_ResourceSchema: pass @property @@ -44,7 +47,7 @@ def required(self) -> Optional[List[str]]: """Resource's required arguments. Note that additional validation may not be represented here""" return self.schema.get("required") - def validate_data(self, data: dict) -> ResourceSchema: + def validate_data(self, data: dict) -> T_ResourceSchema: try: return self.schema_model.parse_obj(data) except ValidationError as e: @@ -80,7 +83,7 @@ def _get_environs(self, prefix: str) -> dict: """ return dict_from_environs(prefix, self.name, list(self.arguments.keys())) - def _process_data(self, data: dict) -> ResourceSchema: + def _process_data(self, data: dict) -> T_ResourceSchema: """ The main method that process all resources data. Validates schema, gets environs, validates data, prepares it via provider requirements, merges defaults and check for data dependencies @@ -96,10 +99,33 @@ def _process_data(self, data: dict) -> ResourceSchema: return data +class ProviderResource(Resource, ABC): + """The base class that is used to fetch provider related resources like rooms, channels, users etc.""" + + @property + @abstractmethod + def resource_name(self) -> str: + pass + + @abstractmethod + def _get_resource(self, data: ResourceSchema) -> dict: + pass + + def __call__(self, **kwargs) -> dict: + data = self._process_data(kwargs) + return self._get_resource(data) + + def __repr__(self) -> str: + return f"" + + +T_ProviderResource = TypeVar("T_ProviderResource", bound=ProviderResource) + + class Provider(Resource, ABC): """The Base class all notification providers inherit from.""" - _resources = {} + _resources: Dict[str, T_ProviderResource] = {} def __repr__(self): return f"" @@ -110,12 +136,12 @@ def __getattr__(self, item): raise AttributeError(f"{self} does not have a property {item}") @property - def base_url(self): - return + def base_url(self) -> str: + return "" @property @abstractmethod - def site_url(self): + def site_url(self) -> str: pass @property @@ -126,7 +152,7 @@ def metadata(self) -> dict: return {"base_url": self.base_url, "site_url": self.site_url, "name": self.name} @property - def resources(self) -> list: + def resources(self) -> List[str]: """Return a list of names of relevant :class:`~notifiers.core.ProviderResource` objects""" return list(self._resources.keys()) @@ -157,21 +183,4 @@ def notify(self, raise_on_errors: bool = False, **kwargs) -> Response: return rsp -class ProviderResource(Resource, ABC): - """The base class that is used to fetch provider related resources like rooms, channels, users etc.""" - - @property - @abstractmethod - def resource_name(self): - pass - - @abstractmethod - def _get_resource(self, data: ResourceSchema): - pass - - def __call__(self, **kwargs): - data = self._process_data(kwargs) - return self._get_resource(data) - - def __repr__(self): - return f"" +T_Provider = TypeVar("T_Provider", bound=Provider) diff --git a/notifiers/models/schema.py b/notifiers/models/schema.py index bac6e67a..5601e9c0 100644 --- a/notifiers/models/schema.py +++ b/notifiers/models/schema.py @@ -2,6 +2,7 @@ from typing import Any from typing import List from typing import Tuple +from typing import TypeVar from typing import Union from pydantic import BaseModel @@ -59,3 +60,6 @@ class Config: allow_population_by_field_name = True extra = Extra.forbid json_encoders = {NameEmail: lambda e: str(e)} + + +T_ResourceSchema = TypeVar("T_ResourceSchema", bound=ResourceSchema) diff --git a/notifiers/providers/__init__.py b/notifiers/providers/__init__.py index 96c32c14..96bac465 100644 --- a/notifiers/providers/__init__.py +++ b/notifiers/providers/__init__.py @@ -1,4 +1,7 @@ # flake8: noqa +from typing import Dict +from typing import Type + from . import email from . import gitter from . import gmail @@ -13,9 +16,10 @@ from . import telegram from . import twilio from . import zulip +from ..models.resource import T_Provider from .slack import Slack -_all_providers = { +_all_providers: Dict[str, Type[T_Provider]] = { "pushover": pushover.Pushover, "simplepush": simplepush.SimplePush, "slack": Slack, From 2c1300b43c1bf2fe80d7655301e09a76e57900d4 Mon Sep 17 00:00:00 2001 From: Or Carmi <4374581+liiight@users.noreply.github.com> Date: Fri, 13 Nov 2020 11:48:07 +0200 Subject: [PATCH 115/137] Update pythonpackage.yml --- .github/workflows/pythonpackage.yml | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index b139596c..d148736c 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -9,7 +9,7 @@ jobs: strategy: max-parallel: 2 matrix: - python-version: [3.6, 3.7] + python-version: [3.6, 3.7, 3.8, 3.9] steps: - uses: actions/checkout@v1 @@ -18,11 +18,7 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install dependencies - run: | - pip install --upgrade pip setuptools - pip install -e . - pip install -r requirements.txt - pip install -r dev-requirements.txt + run: pip install -r requirements.txt - name: Test with pytest run: pytest --cov=./ env: From d4b16a5a450f916dd5f84aea7fcb1d0d44ab2481 Mon Sep 17 00:00:00 2001 From: Or Carmi <4374581+liiight@users.noreply.github.com> Date: Fri, 13 Nov 2020 11:53:11 +0200 Subject: [PATCH 116/137] Update pythonpackage.yml --- .github/workflows/pythonpackage.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index d148736c..952ccf95 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -18,7 +18,9 @@ jobs: with: python-version: ${{ matrix.python-version }} - name: Install dependencies - run: pip install -r requirements.txt + run: | + pip install -r requirements.txt + pip install -r dev-requirements.txt - name: Test with pytest run: pytest --cov=./ env: From d61f6c87377374981898f163b4a771228ed0c65a Mon Sep 17 00:00:00 2001 From: Or Carmi <4374581+liiight@users.noreply.github.com> Date: Fri, 13 Nov 2020 17:27:21 +0200 Subject: [PATCH 117/137] set setuptools --- .github/workflows/pythonpackage.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 952ccf95..332b0c84 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -19,6 +19,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | + pip install setuptools==45 pip install -r requirements.txt pip install -r dev-requirements.txt - name: Test with pytest From f34ff689ec3f0c961db475f66fc9213c9d29811d Mon Sep 17 00:00:00 2001 From: Or Carmi <4374581+liiight@users.noreply.github.com> Date: Fri, 13 Nov 2020 17:29:55 +0200 Subject: [PATCH 118/137] set dev install --- .github/workflows/pythonpackage.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 332b0c84..6597215a 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -20,7 +20,7 @@ jobs: - name: Install dependencies run: | pip install setuptools==45 - pip install -r requirements.txt + pip install -e . pip install -r dev-requirements.txt - name: Test with pytest run: pytest --cov=./ From 63b6dbd85daa1979d66fa8acb6ff896cb6716258 Mon Sep 17 00:00:00 2001 From: liiight Date: Fri, 13 Nov 2020 17:22:57 +0200 Subject: [PATCH 119/137] added typing extensions to requirements --- .pre-commit-config.yaml | 1 - requirements.txt | 3 ++- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 07c3eed8..05d3f37f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -14,7 +14,6 @@ repos: rev: 19.10b0 hooks: - id: black - language_version: python3.6 - repo: https://github.com/asottile/reorder_python_imports rev: v1.6.1 hooks: diff --git a/requirements.txt b/requirements.txt index 2f9defae..fba94b9d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,4 +2,5 @@ requests>=2.21.0 jsonschema>=3.0.0 click>=7.0 rfc3987>=1.3.8 -pydantic[email] \ No newline at end of file +pydantic[email] +typing_extensions \ No newline at end of file From 234b88cbea20de37d5382ad8a5aef3bfc46f719f Mon Sep 17 00:00:00 2001 From: liiight Date: Fri, 13 Nov 2020 17:25:13 +0200 Subject: [PATCH 120/137] added typing extensions to requirements --- requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements.txt b/requirements.txt index fba94b9d..3ccc5223 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,4 @@ jsonschema>=3.0.0 click>=7.0 rfc3987>=1.3.8 pydantic[email] -typing_extensions \ No newline at end of file +typing_extensions; python_version < '3.8' \ No newline at end of file From 35e828f99b4112282fdf31f78d6b4a286f0f1e9d Mon Sep 17 00:00:00 2001 From: liiight Date: Fri, 13 Nov 2020 17:56:00 +0200 Subject: [PATCH 121/137] updated dev requirements --- dev-requirements.txt | 105 ++++++++++++++++++++++--------------------- 1 file changed, 55 insertions(+), 50 deletions(-) diff --git a/dev-requirements.txt b/dev-requirements.txt index d37fab1b..8e1ae445 100644 --- a/dev-requirements.txt +++ b/dev-requirements.txt @@ -2,56 +2,61 @@ # This file is autogenerated by pip-compile # To update, run: # -# pip-compile --output-file dev-requirements.txt dev-requirements.in +# pip-compile dev-requirements.in # -alabaster==0.7.11 # via sphinx -aspy.yaml==1.1.1 # via pre-commit -atomicwrites==1.1.5 # via pytest -attrs==19.3.0 # via hypothesis, pytest -babel==2.6.0 # via sphinx -bumpversion==0.5.3 -certifi==2018.4.16 # via requests -cfgv==2.0.0 # via pre-commit +alabaster==0.7.12 # via sphinx +appdirs==1.4.4 # via virtualenv +attrs==20.3.0 # via hypothesis, pytest +babel==2.9.0 # via sphinx +bump2version==1.0.1 # via bumpversion +bumpversion==0.6.0 # via -r dev-requirements.in +certifi==2020.11.8 # via requests +cfgv==3.2.0 # via pre-commit chardet==3.0.4 # via requests -codecov==2.0.15 -coverage==4.5.1 # via codecov, pytest-cov -decorator==4.4.1 # via retry -docutils==0.14 # via sphinx -hypothesis==5.4.1 -identify==1.1.4 # via pre-commit -idna==2.7 # via requests -imagesize==1.0.0 # via sphinx -importlib-metadata==0.18 # via pluggy, pytest -jinja2==2.10.1 # via sphinx -markupsafe==1.0 # via jinja2 -more-itertools==4.2.0 # via pytest -nodeenv==1.3.2 # via pre-commit -packaging==17.1 # via pytest, sphinx -pluggy==0.12.0 # via pytest -pre-commit==2.0.1 -py==1.5.4 # via pytest, retry -pygments==2.2.0 # via sphinx -pyparsing==2.2.0 # via packaging -pytest-cov==2.7.1 -pytest==5.0.1 -pytz==2018.5 # via babel -pyyaml==5.1 # via aspy.yaml, pre-commit -requests==2.23.0 # via codecov, sphinx -retry==0.9.2 -six==1.11.0 # via cfgv, more-itertools, packaging -snowballstemmer==1.2.1 # via sphinx -sortedcontainers==2.1.0 # via hypothesis -sphinx-autodoc-annotation==1.0.post1 -sphinx-rtd-theme==0.4.3 -sphinx==2.2.1 -sphinxcontrib-applehelp==1.0.1 # via sphinx -sphinxcontrib-devhelp==1.0.1 # via sphinx -sphinxcontrib-htmlhelp==1.0.2 # via sphinx +codecov==2.1.10 # via -r dev-requirements.in +coverage==5.3 # via codecov, pytest-cov +decorator==4.4.2 # via retry +distlib==0.3.1 # via virtualenv +docutils==0.16 # via sphinx +filelock==3.0.12 # via virtualenv +hypothesis==5.41.2 # via -r dev-requirements.in +identify==1.5.9 # via pre-commit +idna==2.10 # via requests +imagesize==1.2.0 # via sphinx +importlib-metadata==2.0.0 # via pluggy, pre-commit, pytest, virtualenv +importlib-resources==3.3.0 # via pre-commit, virtualenv +iniconfig==1.1.1 # via pytest +jinja2==2.11.2 # via sphinx +markupsafe==1.1.1 # via jinja2 +nodeenv==1.5.0 # via pre-commit +packaging==20.4 # via pytest, sphinx +pluggy==0.13.1 # via pytest +pre-commit==2.8.2 # via -r dev-requirements.in +py==1.9.0 # via pytest, retry +pygments==2.7.2 # via sphinx +pyparsing==2.4.7 # via packaging +pytest-cov==2.10.1 # via -r dev-requirements.in +pytest==6.1.2 # via -r dev-requirements.in, pytest-cov +pytz==2020.4 # via babel +pyyaml==5.3.1 # via pre-commit +requests==2.25.0 # via codecov, sphinx +retry==0.9.2 # via -r dev-requirements.in +six==1.15.0 # via packaging, virtualenv +snowballstemmer==2.0.0 # via sphinx +sortedcontainers==2.3.0 # via hypothesis +sphinx-autodoc-annotation==1.0.post1 # via -r dev-requirements.in +sphinx-rtd-theme==0.5.0 # via -r dev-requirements.in +sphinx==3.3.1 # via -r dev-requirements.in, sphinx-autodoc-annotation, sphinx-rtd-theme +sphinxcontrib-applehelp==1.0.2 # via sphinx +sphinxcontrib-devhelp==1.0.2 # via sphinx +sphinxcontrib-htmlhelp==1.0.3 # via sphinx sphinxcontrib-jsmath==1.0.1 # via sphinx -sphinxcontrib-qthelp==1.0.2 # via sphinx -sphinxcontrib-serializinghtml==1.1.3 # via sphinx -toml==0.9.4 # via pre-commit -urllib3==1.24.2 # via requests -virtualenv==16.0.0 # via pre-commit -wcwidth==0.1.7 # via pytest -zipp==0.5.1 # via importlib-metadata +sphinxcontrib-qthelp==1.0.3 # via sphinx +sphinxcontrib-serializinghtml==1.1.4 # via sphinx +toml==0.10.2 # via pre-commit, pytest +urllib3==1.26.2 # via requests +virtualenv==20.1.0 # via pre-commit +zipp==3.4.0 # via importlib-metadata, importlib-resources + +# The following packages are considered to be unsafe in a requirements file: +# setuptools From b050ce2cf8c5f12aeb4ff403f75321e9540fef91 Mon Sep 17 00:00:00 2001 From: liiight Date: Fri, 13 Nov 2020 18:03:21 +0200 Subject: [PATCH 122/137] catch import error --- notifiers/providers/mailgun.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/notifiers/providers/mailgun.py b/notifiers/providers/mailgun.py index 07cfd5a8..0c228ec1 100644 --- a/notifiers/providers/mailgun.py +++ b/notifiers/providers/mailgun.py @@ -13,7 +13,11 @@ from pydantic import NameEmail from pydantic import root_validator from pydantic import validator -from typing_extensions import Literal + +try: + from typing import Literal +except ImportError: + from typing_extensions import Literal from ..models.resource import Provider from ..models.response import Response From d6733b9966938213b0e8495aed5d9805c6ca054e Mon Sep 17 00:00:00 2001 From: liiight Date: Fri, 13 Nov 2020 18:07:16 +0200 Subject: [PATCH 123/137] removed limit for typing_extensions --- notifiers/providers/mailgun.py | 6 +----- requirements.txt | 2 +- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/notifiers/providers/mailgun.py b/notifiers/providers/mailgun.py index 0c228ec1..07cfd5a8 100644 --- a/notifiers/providers/mailgun.py +++ b/notifiers/providers/mailgun.py @@ -13,11 +13,7 @@ from pydantic import NameEmail from pydantic import root_validator from pydantic import validator - -try: - from typing import Literal -except ImportError: - from typing_extensions import Literal +from typing_extensions import Literal from ..models.resource import Provider from ..models.response import Response diff --git a/requirements.txt b/requirements.txt index 3ccc5223..fba94b9d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,4 +3,4 @@ jsonschema>=3.0.0 click>=7.0 rfc3987>=1.3.8 pydantic[email] -typing_extensions; python_version < '3.8' \ No newline at end of file +typing_extensions \ No newline at end of file From df2a7c0ca88ffc179f9e855b4fdd4ef9564e371c Mon Sep 17 00:00:00 2001 From: liiight Date: Fri, 13 Nov 2020 22:58:05 +0200 Subject: [PATCH 124/137] added environ tests that loads types. Closes #387 --- notifiers/utils/helpers.py | 6 ++++-- tests/conftest.py | 2 +- tests/test_utils.py | 27 +++++++++++++++++++++++++++ 3 files changed, 32 insertions(+), 3 deletions(-) diff --git a/notifiers/utils/helpers.py b/notifiers/utils/helpers.py index 88d56c30..00983e19 100644 --- a/notifiers/utils/helpers.py +++ b/notifiers/utils/helpers.py @@ -44,10 +44,12 @@ def dict_from_environs(prefix: str, name: str, args: list) -> dict: """ # todo consider changing via the environs lib log.debug("starting to collect environs using prefix: '%s'", prefix) + prefix = f'{prefix.rstrip("_")}_'.upper() + name = f'{name.rstrip("_")}_'.upper() return { - arg: os.environ.get(f"{prefix}{name}_{arg}".upper()) + arg: os.environ.get(f"{prefix}{name}{arg}".upper()) for arg in args - if os.environ.get(f"{prefix}{name}_{arg}".upper()) + if os.environ.get(f"{prefix}{name}{arg}".upper()) } diff --git a/tests/conftest.py b/tests/conftest.py index 83e7c2d4..74d71278 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -20,7 +20,7 @@ from notifiers.providers import _all_providers from notifiers.utils.helpers import text_to_bool -log = logging.getLogger(__name__) +log = logging.getLogger("notifiers") class MockProxy: diff --git a/tests/test_utils.py b/tests/test_utils.py index 6b317231..a4b4ee77 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,5 +1,6 @@ import pytest +from notifiers.models.schema import ResourceSchema from notifiers.utils.helpers import dict_from_environs from notifiers.utils.helpers import merge_dicts from notifiers.utils.helpers import snake_to_camel_case @@ -7,6 +8,14 @@ from notifiers.utils.requests import file_list_for_request +class TypeTest(ResourceSchema): + string = "" + integer = 0 + float_ = 0.1 + bool_ = True + bytes = b"" + + class TestHelpers: @pytest.mark.parametrize( "text, result", @@ -71,3 +80,21 @@ def test_file_list_for_request(self, tmp_path): file_list_2 = file_list_for_request([file_1, file_2], "foo", "foo_mimetype") assert len(file_list_2) == 2 assert all(len(member[1]) == 3 for member in file_list_2) + + def test_schema_from_environs(self, monkeypatch): + prefix = "NOTIFIERS" + name = "ENV_TEST" + env_data = { + "string": "foo", + "integer": "8", + "float_": "1.1", + "bool_": "true", + "bytes": "baz", + } + for key, value in env_data.items(): + monkeypatch.setenv(f"{prefix}_{name}_{key}".upper(), value) + + data = dict_from_environs(prefix, name, list(env_data)) + assert TypeTest.parse_obj(data) == TypeTest( + string="foo", integer=8, float_=1.1, bool_=True, bytes=b"baz" + ) From 2a4d65a3dd0988274bc79155ef1d81e1c13c74d0 Mon Sep 17 00:00:00 2001 From: liiight Date: Fri, 13 Nov 2020 23:22:40 +0200 Subject: [PATCH 125/137] tweaked dict_from_environs --- notifiers/utils/helpers.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/notifiers/utils/helpers.py b/notifiers/utils/helpers.py index 00983e19..20961fd4 100644 --- a/notifiers/utils/helpers.py +++ b/notifiers/utils/helpers.py @@ -1,6 +1,7 @@ import logging import os from distutils.util import strtobool +from typing import Sequence log = logging.getLogger("notifiers") @@ -32,7 +33,7 @@ def merge_dicts(target_dict: dict, merge_dict: dict) -> dict: return target_dict -def dict_from_environs(prefix: str, name: str, args: list) -> dict: +def dict_from_environs(prefix: str, name: str, args: Sequence[str]) -> dict: """ Return a dict of environment variables correlating to the arguments list, main name and prefix like so: [prefix]_[name]_[arg] @@ -42,15 +43,18 @@ def dict_from_environs(prefix: str, name: str, args: list) -> dict: :param args: List of args to iterate over :return: A dict of found environ values """ - # todo consider changing via the environs lib log.debug("starting to collect environs using prefix: '%s'", prefix) prefix = f'{prefix.rstrip("_")}_'.upper() name = f'{name.rstrip("_")}_'.upper() - return { - arg: os.environ.get(f"{prefix}{name}{arg}".upper()) - for arg in args - if os.environ.get(f"{prefix}{name}{arg}".upper()) - } + data = {} + for arg in args: + env_key = f"{prefix}{name}{arg}".upper() + log.debug("Looking for environment variable %s", env_key) + value = os.environ.get(env_key) + if value: + log.debug("Found environment variable %s, adding", env_key) + data[arg] = value + return data def snake_to_camel_case(value: str) -> str: From 8e81b967701e296db04208f6b336eb1e5b4d7e31 Mon Sep 17 00:00:00 2001 From: liiight Date: Fri, 13 Nov 2020 23:24:39 +0200 Subject: [PATCH 126/137] removed text_to_bool util as it is no longer needed --- notifiers/utils/helpers.py | 13 ------------- tests/conftest.py | 10 ---------- tests/test_utils.py | 19 ------------------- 3 files changed, 42 deletions(-) diff --git a/notifiers/utils/helpers.py b/notifiers/utils/helpers.py index 20961fd4..fa71e7e0 100644 --- a/notifiers/utils/helpers.py +++ b/notifiers/utils/helpers.py @@ -1,23 +1,10 @@ import logging import os -from distutils.util import strtobool from typing import Sequence log = logging.getLogger("notifiers") -def text_to_bool(value: str) -> bool: - """ - Tries to convert a text value to a bool. If unsuccessful returns if value is None or not - - :param value: Value to check - """ - try: - return bool(strtobool(value)) - except (ValueError, AttributeError): - return value is not None - - def merge_dicts(target_dict: dict, merge_dict: dict) -> dict: """ Merges ``merge_dict`` into ``target_dict`` if the latter does not already contain a value for each of the key diff --git a/tests/conftest.py b/tests/conftest.py index 74d71278..83c2a87b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,7 +18,6 @@ from notifiers.models.response import ResponseStatus from notifiers.models.schema import ResourceSchema from notifiers.providers import _all_providers -from notifiers.utils.helpers import text_to_bool log = logging.getLogger("notifiers") @@ -135,15 +134,6 @@ def return_handler(provider_name, logging_level, data=None, **kwargs): return return_handler -def pytest_runtest_setup(item): - """Skips PRs if secure env vars are set and test is marked as online""" - pull_request = text_to_bool(os.environ.get("TRAVIS_PULL_REQUEST")) - secure_env_vars = text_to_bool(os.environ.get("TRAVIS_SECURE_ENV_VARS")) - online = item.get_closest_marker("online") - if online and pull_request and not secure_env_vars: - pytest.skip("skipping online tests via PRs") - - @pytest.fixture def test_message(request): message = os.environ.get("TRAVIS_BUILD_WEB_URL") or "Local test" diff --git a/tests/test_utils.py b/tests/test_utils.py index a4b4ee77..efd1724a 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -4,7 +4,6 @@ from notifiers.utils.helpers import dict_from_environs from notifiers.utils.helpers import merge_dicts from notifiers.utils.helpers import snake_to_camel_case -from notifiers.utils.helpers import text_to_bool from notifiers.utils.requests import file_list_for_request @@ -17,24 +16,6 @@ class TypeTest(ResourceSchema): class TestHelpers: - @pytest.mark.parametrize( - "text, result", - [ - ("y", True), - ("yes", True), - ("true", True), - ("on", True), - ("no", False), - ("off", False), - ("false", False), - ("0", False), - ("foo", True), - ("bla", True), - ], - ) - def test_text_to_bool(self, text, result): - assert text_to_bool(text) is result - @pytest.mark.parametrize( "target_dict, merge_dict, result", [ From 0fbd6aa09f7bf60594f6eb4c5c4f771e14208358 Mon Sep 17 00:00:00 2001 From: liiight Date: Fri, 13 Nov 2020 23:45:28 +0200 Subject: [PATCH 127/137] change provider registry to be dynamically init --- notifiers/__init__.py | 1 + notifiers/core.py | 10 ++++---- notifiers/models/resource.py | 19 ++++++++------- notifiers/providers/__init__.py | 24 +------------------ tests/conftest.py | 5 ++-- .../providers/test_generic_provider_tests.py | 4 ++-- tests/test_utils.py | 2 +- 7 files changed, 21 insertions(+), 44 deletions(-) diff --git a/notifiers/__init__.py b/notifiers/__init__.py index bb3bb5f7..ee11f3aa 100644 --- a/notifiers/__init__.py +++ b/notifiers/__init__.py @@ -1,5 +1,6 @@ import logging +from . import providers # noqa: F401 from ._version import __version__ from .core import all_providers from .core import get_notifier diff --git a/notifiers/core.py b/notifiers/core.py index 94f491d9..5ca33efc 100644 --- a/notifiers/core.py +++ b/notifiers/core.py @@ -2,14 +2,12 @@ from typing import List from .exceptions import NoSuchNotifierError +from .models.resource import provider_registry from .models.resource import T_Provider from .models.response import Response log = logging.getLogger("notifiers") -# Avoid premature import -from .providers import _all_providers # noqa: E402 - def get_notifier(provider_name: str, strict: bool = False) -> T_Provider: """ @@ -20,16 +18,16 @@ def get_notifier(provider_name: str, strict: bool = False) -> T_Provider: :return: :class:`Provider` or None :raises ValueError: In case ``strict`` is True and provider not found """ - if provider_name in _all_providers: + if provider_name in provider_registry: log.debug("found a match for '%s', returning", provider_name) - return _all_providers[provider_name]() + return provider_registry[provider_name]() elif strict: raise NoSuchNotifierError(name=provider_name) def all_providers() -> List[str]: """Returns a list of all :class:`~notifiers.core.Provider` names""" - return list(_all_providers.keys()) + return list(provider_registry.keys()) def notify(provider_name: str, **kwargs) -> Response: diff --git a/notifiers/models/resource.py b/notifiers/models/resource.py index 1d045562..db2b32cd 100644 --- a/notifiers/models/resource.py +++ b/notifiers/models/resource.py @@ -3,6 +3,7 @@ from typing import Dict from typing import List from typing import Optional +from typing import Type from typing import TypeVar import requests @@ -22,15 +23,8 @@ class Resource(ABC): """Base class that represent an object holding a schema and its utility methods""" - @property - @abstractmethod - def schema_model(self) -> T_ResourceSchema: - pass - - @property - @abstractmethod - def name(self) -> str: - """Resource provider name""" + name: str + schema_model: T_ResourceSchema @property def schema(self) -> dict: @@ -127,6 +121,10 @@ class Provider(Resource, ABC): _resources: Dict[str, T_ProviderResource] = {} + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + provider_registry[cls.name] = cls + def __repr__(self): return f"" @@ -184,3 +182,6 @@ def notify(self, raise_on_errors: bool = False, **kwargs) -> Response: T_Provider = TypeVar("T_Provider", bound=Provider) + + +provider_registry: Dict[str, Type[T_Provider]] = {} diff --git a/notifiers/providers/__init__.py b/notifiers/providers/__init__.py index 96bac465..baf5b074 100644 --- a/notifiers/providers/__init__.py +++ b/notifiers/providers/__init__.py @@ -1,7 +1,4 @@ # flake8: noqa -from typing import Dict -from typing import Type - from . import email from . import gitter from . import gmail @@ -12,27 +9,8 @@ from . import pushbullet from . import pushover from . import simplepush +from . import slack from . import statuspage from . import telegram from . import twilio from . import zulip -from ..models.resource import T_Provider -from .slack import Slack - -_all_providers: Dict[str, Type[T_Provider]] = { - "pushover": pushover.Pushover, - "simplepush": simplepush.SimplePush, - "slack": Slack, - "email": email.SMTP, - "gmail": gmail.Gmail, - "telegram": telegram.Telegram, - "gitter": gitter.Gitter, - "pushbullet": pushbullet.Pushbullet, - "join": join.Join, - "zulip": zulip.Zulip, - "twilio": twilio.Twilio, - "pagerduty": pagerduty.PagerDuty, - "mailgun": mailgun.MailGun, - "popcornnotify": popcornnotify.PopcornNotify, - "statuspage": statuspage.Statuspage, -} diff --git a/tests/conftest.py b/tests/conftest.py index 83c2a87b..2e772c85 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -13,11 +13,11 @@ from notifiers.core import get_notifier from notifiers.logging import NotificationHandler from notifiers.models.resource import Provider +from notifiers.models.resource import provider_registry from notifiers.models.resource import ProviderResource from notifiers.models.response import Response from notifiers.models.response import ResponseStatus from notifiers.models.schema import ResourceSchema -from notifiers.providers import _all_providers log = logging.getLogger("notifiers") @@ -77,7 +77,6 @@ def mock_rsrc(self): @pytest.fixture(scope="session") def mock_provider(): """Return a generic :class:`notifiers.core.Provider` class""" - _all_providers.update({MockProvider.name: MockProvider}) return MockProvider() @@ -119,7 +118,7 @@ def cli_runner(monkeypatch): def magic_mock_provider(monkeypatch): MockProvider.notify = MagicMock() MockProxy.name = "magic_mock" - monkeypatch.setitem(_all_providers, MockProvider.name, MockProvider) + monkeypatch.setitem(provider_registry, MockProvider.name, MockProvider) return MockProvider() diff --git a/tests/providers/test_generic_provider_tests.py b/tests/providers/test_generic_provider_tests.py index a5441456..9343840a 100644 --- a/tests/providers/test_generic_provider_tests.py +++ b/tests/providers/test_generic_provider_tests.py @@ -1,9 +1,9 @@ import pytest -from notifiers.core import _all_providers +from notifiers.models.resource import provider_registry -@pytest.mark.parametrize("provider", _all_providers.values()) +@pytest.mark.parametrize("provider", provider_registry.values()) class TestProviders: def test_provider_metadata(self, provider): provider = provider() diff --git a/tests/test_utils.py b/tests/test_utils.py index efd1724a..43ad5d02 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -32,7 +32,7 @@ def test_merge_dict(self, target_dict, merge_dict, result): ) def test_dict_from_environs(self, prefix, name, args, result, monkeypatch): for arg in args: - environ = f"{prefix}{name}_{arg}".upper() + environ = f"{prefix}_{name}_{arg}".upper() monkeypatch.setenv(environ, "baz") assert dict_from_environs(prefix, name, args) == result From 9c7dd849116eaf0d5f586e7a851e499df3ffd34c Mon Sep 17 00:00:00 2001 From: liiight Date: Sat, 14 Nov 2020 00:15:07 +0200 Subject: [PATCH 128/137] added pytest options --- pyproject.toml | 11 +++++++++++ pytest.ini | 4 ---- 2 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 pyproject.toml delete mode 100644 pytest.ini diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..86ea1fb1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,11 @@ +[tool.pytest.ini_options] +console_output_style = 'progress' +minversion = '6.0' +addopts = '--color=yes --tb=native -l --code-highlight=yes' +log_cli = true +log_cli_level = 'debug' +log_format = '%(asctime)s | %(levelname)s | %(name)s | %(message)s' +log_date_format = '%Y-%m-%d %H:%M:%S' +markers = [ + 'online: marks tests running online', +] \ No newline at end of file diff --git a/pytest.ini b/pytest.ini deleted file mode 100644 index 6cf432b9..00000000 --- a/pytest.ini +++ /dev/null @@ -1,4 +0,0 @@ -[pytest] -markers = - online: marks tests running online - serial \ No newline at end of file From e9931b06fdf2b2c1979b45d02ad32af25236d669 Mon Sep 17 00:00:00 2001 From: liiight Date: Sat, 14 Nov 2020 00:16:58 +0200 Subject: [PATCH 129/137] updated pre-commit --- .pre-commit-config.yaml | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 05d3f37f..7a69d479 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,20 +1,24 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v2.3.0 + rev: v3.3.0 hooks: + - id: check-toml - id: check-yaml - id: check-json - id: pretty-format-json args: ['--autofix'] - id: trailing-whitespace - - id: flake8 - args: ['--max-line-length=120'] - additional_dependencies: ['flake8-mutable', 'flake8-comprehensions'] - repo: https://github.com/psf/black rev: 19.10b0 hooks: - id: black - repo: https://github.com/asottile/reorder_python_imports - rev: v1.6.1 + rev: v2.3.5 + hooks: + - id: reorder-python-imports + - repo: https://gitlab.com/pycqa/flake8 + rev: 3.8.3 hooks: - - id: reorder-python-imports \ No newline at end of file + - id: flake8 + args: [ '--max-line-length=120',] + additional_dependencies: [ 'flake8-mutable', 'flake8-comprehensions', 'flake8-bugbear' ] \ No newline at end of file From f351c4f054b50ce840a344bd31353897714aa291 Mon Sep 17 00:00:00 2001 From: liiight Date: Sat, 14 Nov 2020 00:18:55 +0200 Subject: [PATCH 130/137] log tweak --- notifiers/utils/helpers.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/notifiers/utils/helpers.py b/notifiers/utils/helpers.py index fa71e7e0..518afbb8 100644 --- a/notifiers/utils/helpers.py +++ b/notifiers/utils/helpers.py @@ -30,7 +30,9 @@ def dict_from_environs(prefix: str, name: str, args: Sequence[str]) -> dict: :param args: List of args to iterate over :return: A dict of found environ values """ - log.debug("starting to collect environs using prefix: '%s'", prefix) + log.debug( + "starting to collect environs using prefix: '%s' and name '%s'", prefix, name + ) prefix = f'{prefix.rstrip("_")}_'.upper() name = f'{name.rstrip("_")}_'.upper() data = {} @@ -39,8 +41,8 @@ def dict_from_environs(prefix: str, name: str, args: Sequence[str]) -> dict: log.debug("Looking for environment variable %s", env_key) value = os.environ.get(env_key) if value: - log.debug("Found environment variable %s, adding", env_key) data[arg] = value + log.debug("Returning data %s from environment variables", data) return data From 1fbe224d1407787cc2298f56f24a4fac1990e3aa Mon Sep 17 00:00:00 2001 From: liiight Date: Sat, 14 Nov 2020 00:27:54 +0200 Subject: [PATCH 131/137] added test to load data using schema aliases from environment variables --- tests/test_utils.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/tests/test_utils.py b/tests/test_utils.py index 43ad5d02..17de8539 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,4 +1,5 @@ import pytest +from pydantic import Field from notifiers.models.schema import ResourceSchema from notifiers.utils.helpers import dict_from_environs @@ -10,8 +11,8 @@ class TypeTest(ResourceSchema): string = "" integer = 0 - float_ = 0.1 - bool_ = True + float_ = Field(0.1, alias="floatAlias") + bool_ = Field(True, alias="boolAlias") bytes = b"" @@ -79,3 +80,16 @@ def test_schema_from_environs(self, monkeypatch): assert TypeTest.parse_obj(data) == TypeTest( string="foo", integer=8, float_=1.1, bool_=True, bytes=b"baz" ) + + def test_schema_aliases_from_environs(self, monkeypatch): + prefix = "NOTIFIERS" + name = "ENV_TEST" + env_data = { + "floatAlias": "1.1", + "boolAlias": "true", + } + for key, value in env_data.items(): + monkeypatch.setenv(f"{prefix}_{name}_{key}".upper(), value) + + data = dict_from_environs(prefix, name, list(env_data)) + assert TypeTest.parse_obj(data) == TypeTest(float_=1.1, bool_=True) From f782af04b23bdfc8c270cf9a2049961c569eea75 Mon Sep 17 00:00:00 2001 From: liiight Date: Sat, 14 Nov 2020 01:26:39 +0200 Subject: [PATCH 132/137] fixed twilio --- notifiers/models/resource.py | 22 ++++++++++++++-------- notifiers/models/schema.py | 9 +++++++++ notifiers/providers/twilio.py | 4 ++-- notifiers/utils/helpers.py | 9 +++++---- tests/providers/test_twilio.py | 1 - 5 files changed, 30 insertions(+), 15 deletions(-) diff --git a/notifiers/models/resource.py b/notifiers/models/resource.py index db2b32cd..02222575 100644 --- a/notifiers/models/resource.py +++ b/notifiers/models/resource.py @@ -1,5 +1,6 @@ from abc import ABC from abc import abstractmethod +from itertools import chain from typing import Dict from typing import List from typing import Optional @@ -26,20 +27,25 @@ class Resource(ABC): name: str schema_model: T_ResourceSchema - @property - def schema(self) -> dict: + def schema(self, by_alias: bool = True) -> dict: """Resource's JSON schema as a dict""" - return self.schema_model.schema() + return self.schema_model.schema(by_alias=by_alias) - @property - def arguments(self) -> dict: + def arguments(self, by_alias: bool = True) -> dict: """Resource's arguments""" - return self.schema["properties"] + return self.schema(by_alias=by_alias)["properties"] + + @property + def all_fields(self) -> List[str]: + """All schema field, including by alias and by attribute name""" + return list( + chain(self.arguments().keys(), self.arguments(by_alias=False).keys()) + ) @property def required(self) -> Optional[List[str]]: """Resource's required arguments. Note that additional validation may not be represented here""" - return self.schema.get("required") + return self.schema().get("required") def validate_data(self, data: dict) -> T_ResourceSchema: try: @@ -75,7 +81,7 @@ def _get_environs(self, prefix: str) -> dict: :param prefix: The environ prefix to use. If not supplied, uses the default :return: A dict of arguments and value retrieved from environs """ - return dict_from_environs(prefix, self.name, list(self.arguments.keys())) + return dict_from_environs(prefix, self.name, self.all_fields) def _process_data(self, data: dict) -> T_ResourceSchema: """ diff --git a/notifiers/models/schema.py b/notifiers/models/schema.py index 5601e9c0..70a4cb7d 100644 --- a/notifiers/models/schema.py +++ b/notifiers/models/schema.py @@ -15,6 +15,15 @@ class ResourceSchema(BaseModel): _values_to_exclude: Tuple[str, ...] = () + @property + def field_names(self) -> List[str]: + names = [] + for field, model in self.__fields__.items(): + names.append(field) + if model.alias: + names.append(model.alias) + return names + @staticmethod def to_list(value: Union[Any, List[Any]]) -> List[Any]: """Helper method to make sure a return value is a list""" diff --git a/notifiers/providers/twilio.py b/notifiers/providers/twilio.py index 9ec9d26d..aec579f2 100644 --- a/notifiers/providers/twilio.py +++ b/notifiers/providers/twilio.py @@ -93,7 +93,7 @@ class TwilioSchema(ResourceSchema): "that is enabled for the type of message you want to send. Phone numbers or short codes purchased " "from Twilio also work here. You cannot, for example, spoof messages from a private cell phone " "number. If you are using messaging_service_sid, this parameter must be empty", - alias="from", + alias="From", ) messaging_service_sid: str = Field( None, @@ -103,7 +103,7 @@ class TwilioSchema(ResourceSchema): "Twilio will use your enabled Copilot Features to select the from phone number for delivery", ) message: constr(min_length=1, max_length=1600) = Field( - None, description="The text of the message you want to send", alias="body" + None, description="The text of the message you want to send", alias="Body" ) media_url: ResourceSchema.one_or_more_of(HttpUrl) = Field( None, diff --git a/notifiers/utils/helpers.py b/notifiers/utils/helpers.py index 518afbb8..27b3e612 100644 --- a/notifiers/utils/helpers.py +++ b/notifiers/utils/helpers.py @@ -33,11 +33,12 @@ def dict_from_environs(prefix: str, name: str, args: Sequence[str]) -> dict: log.debug( "starting to collect environs using prefix: '%s' and name '%s'", prefix, name ) - prefix = f'{prefix.rstrip("_")}_'.upper() - name = f'{name.rstrip("_")}_'.upper() + prefix = f'{prefix.rstrip("_")}_' + name = f'{name.rstrip("_")}_' data = {} - for arg in args: - env_key = f"{prefix}{name}{arg}".upper() + + env_to_arg_dict = {f"{prefix}{name}{arg}".upper(): arg for arg in args} + for env_key, arg in env_to_arg_dict.items(): log.debug("Looking for environment variable %s", env_key) value = os.environ.get(env_key) if value: diff --git a/tests/providers/test_twilio.py b/tests/providers/test_twilio.py index 5d8726ad..fac47149 100644 --- a/tests/providers/test_twilio.py +++ b/tests/providers/test_twilio.py @@ -4,7 +4,6 @@ class TestTwilio: - @pytest.mark.skip("Skip until environs are fixed") @pytest.mark.online def test_sanity(self, provider, test_message): data = {"message": test_message} From da55dac4184ac728423ca7b9632e0c906ac8e685 Mon Sep 17 00:00:00 2001 From: Or Carmi <4374581+liiight@users.noreply.github.com> Date: Sat, 14 Nov 2020 01:40:27 +0200 Subject: [PATCH 133/137] fix codecov --- .github/workflows/pythonpackage.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index 6597215a..1e01c04f 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -23,7 +23,7 @@ jobs: pip install -e . pip install -r dev-requirements.txt - name: Test with pytest - run: pytest --cov=./ + run: pytest --cov=./ --cov-report=xml env: NOTIFIERS_EMAIL_PASSWORD: ${{secrets.NOTIFIERS_EMAIL_PASSWORD}} NOTIFIERS_EMAIL_TO: ${{secrets.NOTIFIERS_EMAIL_TO}} @@ -59,6 +59,6 @@ jobs: NOTIFIERS_ZULIP_TO: ${{secrets.NOTIFIERS_ZULIP_TO}} - name: Upload coverage to Codecov if: success() - uses: codecov/codecov-action@v1.0.2 + uses: codecov/codecov-action@v1.0.14 with: - token: ${{secrets.CODECOV_TOKEN}} + file: ./coverage.xml From 0e55dcdcea5b802f56b6fb15e01f336c8217bdab Mon Sep 17 00:00:00 2001 From: liiight Date: Sat, 14 Nov 2020 01:35:04 +0200 Subject: [PATCH 134/137] tweak --- notifiers/utils/helpers.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/notifiers/utils/helpers.py b/notifiers/utils/helpers.py index 27b3e612..a05fb84d 100644 --- a/notifiers/utils/helpers.py +++ b/notifiers/utils/helpers.py @@ -33,11 +33,17 @@ def dict_from_environs(prefix: str, name: str, args: Sequence[str]) -> dict: log.debug( "starting to collect environs using prefix: '%s' and name '%s'", prefix, name ) + # Make sure prefix and name end with '_' prefix = f'{prefix.rstrip("_")}_' name = f'{name.rstrip("_")}_' + data = {} - env_to_arg_dict = {f"{prefix}{name}{arg}".upper(): arg for arg in args} + # In order to dedupe fields that are equal to their alias, build a dict matching desired environment variable to arg + env_to_arg_dict = {} + for arg in args: + env_to_arg_dict.setdefault(f"{prefix}{name}{arg}".upper(), arg) + for env_key, arg in env_to_arg_dict.items(): log.debug("Looking for environment variable %s", env_key) value = os.environ.get(env_key) From 2dab13c0f908bdded543d824729aebfa792bce02 Mon Sep 17 00:00:00 2001 From: liiight Date: Sat, 14 Nov 2020 01:44:14 +0200 Subject: [PATCH 135/137] fixed tests --- tests/providers/test_join.py | 4 ++-- tests/providers/test_pushover.py | 4 ++-- tests/providers/test_statuspage.py | 2 +- tests/providers/test_telegram.py | 4 ++-- tests/test_core.py | 8 ++++---- 5 files changed, 11 insertions(+), 11 deletions(-) diff --git a/tests/providers/test_join.py b/tests/providers/test_join.py index 8b8bc9fb..7fb819b5 100644 --- a/tests/providers/test_join.py +++ b/tests/providers/test_join.py @@ -28,7 +28,7 @@ class TestJoinDevices: resource = "devices" def test_join_devices_attribs(self, resource): - assert resource.schema == { + assert resource.schema() == { "additionalProperties": False, "description": "The base class for Schemas", "properties": { @@ -66,7 +66,7 @@ def test_join_devices_negative(self, cli_runner): @pytest.mark.skip("tests fail due to no device connected") @pytest.mark.online def test_join_updates_positive(self, cli_runner): - cmd = f"join devices".split() + cmd = "join devices".split() result = cli_runner(cmd) assert not result.exit_code replies = ["You have no devices associated with this apikey", "Device name: "] diff --git a/tests/providers/test_pushover.py b/tests/providers/test_pushover.py index 972c53e4..a5ee1572 100644 --- a/tests/providers/test_pushover.py +++ b/tests/providers/test_pushover.py @@ -76,7 +76,7 @@ class TestPushoverSoundsResource: resource = "sounds" def test_pushover_sounds_attribs(self, resource): - assert resource.schema == { + assert resource.schema() == { "additionalProperties": False, "description": "Pushover base schema", "properties": { @@ -106,7 +106,7 @@ class TestPushoverLimitsResource: resource = "limits" def test_pushover_limits_attribs(self, resource): - assert resource.schema == { + assert resource.schema() == { "additionalProperties": False, "description": "Pushover base schema", "properties": { diff --git a/tests/providers/test_statuspage.py b/tests/providers/test_statuspage.py index e5dc1078..c013490e 100644 --- a/tests/providers/test_statuspage.py +++ b/tests/providers/test_statuspage.py @@ -91,7 +91,7 @@ class TestStatuspageComponents: resource = "components" def test_statuspage_components_attribs(self, resource): - assert resource.schema == { + assert resource.schema() == { "additionalProperties": False, "description": "The base class for Schemas", "properties": { diff --git a/tests/providers/test_telegram.py b/tests/providers/test_telegram.py index d93fd41b..b8c72c31 100644 --- a/tests/providers/test_telegram.py +++ b/tests/providers/test_telegram.py @@ -48,7 +48,7 @@ class TestTelegramResources: resource = "updates" def test_telegram_updates_attribs(self, resource): - assert resource.schema == { + assert resource.schema() == { "additionalProperties": False, "description": "The base class for Schemas", "properties": { @@ -87,7 +87,7 @@ def test_telegram_updates_negative(self, cli_runner): @pytest.mark.online @retry(AssertionError, tries=3, delay=10) def test_telegram_updates_positive(self, cli_runner): - cmd = f"telegram updates".split() + cmd = "telegram updates".split() result = cli_runner(cmd) assert not result.exit_code reply = json.loads(result.output) diff --git a/tests/test_core.py b/tests/test_core.py index 76ea0174..e762299f 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -22,7 +22,7 @@ def test_sanity(self, mock_provider): "name": "mock_provider", "site_url": "https://www.mock.com", } - assert mock_provider.arguments == { + assert mock_provider.arguments() == { "not_required": { "title": "Not Required", "description": "example for not required arg", @@ -114,7 +114,7 @@ def test_error_response(self, mock_provider): def test_environs(self, mock_provider, monkeypatch): """Test environs usage""" - prefix = f"mock_" + prefix = "mock_" monkeypatch.setenv(f"{prefix}{mock_provider.name}_required".upper(), "foo") rsp = mock_provider.notify(env_prefix=prefix) assert rsp.status is ResponseStatus.SUCCESS @@ -124,7 +124,7 @@ def test_provided_data_takes_precedence_over_environ( self, mock_provider, monkeypatch ): """Verify that given data overrides environ""" - prefix = f"mock_" + prefix = "mock_" monkeypatch.setenv(f"{prefix}{mock_provider.name}_required".upper(), "foo") rsp = mock_provider.notify(required="bar", env_prefix=prefix) assert rsp.status is ResponseStatus.SUCCESS @@ -145,7 +145,7 @@ def test_resources(self, mock_provider): ) assert resource.resource_name == "mock_resource" assert resource.name == mock_provider.name - assert resource.schema == { + assert resource.schema() == { "title": "MockResourceSchema", "description": "The base class for Schemas", "type": "object", From 94292243176c11b2aa35000909599add2449ad62 Mon Sep 17 00:00:00 2001 From: liiight Date: Sat, 14 Nov 2020 01:49:18 +0200 Subject: [PATCH 136/137] fixed some flake8 stuff --- notifiers/providers/email.py | 2 -- notifiers/utils/requests.py | 4 ++-- notifiers_cli/core.py | 2 +- source/providers/telegram.rst | 2 +- tests/providers/test_gitter.py | 2 +- tests/test_cli.py | 2 +- 6 files changed, 6 insertions(+), 8 deletions(-) diff --git a/notifiers/providers/email.py b/notifiers/providers/email.py index 33a8882b..5db8ad14 100644 --- a/notifiers/providers/email.py +++ b/notifiers/providers/email.py @@ -144,8 +144,6 @@ def _send_notification(self, data: SMTPSchema) -> Response: SMTPServerDisconnected, SMTPSenderRefused, socket.error, - OSError, - IOError, SMTPAuthenticationError, ) as e: errors = [str(e)] diff --git a/notifiers/utils/requests.py b/notifiers/utils/requests.py index 260e6721..87bd2755 100644 --- a/notifiers/utils/requests.py +++ b/notifiers/utils/requests.py @@ -33,8 +33,8 @@ def request( :return: Dict of response body or original :class:`requests.Response` """ session = kwargs.get("session", requests.Session()) - if 'timeout' not in kwargs: - kwargs['timeout'] = (5, 20) + if "timeout" not in kwargs: + kwargs["timeout"] = (5, 20) log.debug( "sending a %s request to %s with args: %s kwargs: %s", method.upper(), diff --git a/notifiers_cli/core.py b/notifiers_cli/core.py index ad7c38b7..8f3c50ca 100644 --- a/notifiers_cli/core.py +++ b/notifiers_cli/core.py @@ -86,7 +86,7 @@ def entry_point(): provider_group_factory() notifiers_cli(obj={}) except NotifierException as e: - click.secho(f"ERROR: {e.message}", bold=True, fg="red") + click.secho(f"ERROR: {e}", bold=True, fg="red") exit(1) diff --git a/source/providers/telegram.rst b/source/providers/telegram.rst index f0adab46..9e4c43d6 100644 --- a/source/providers/telegram.rst +++ b/source/providers/telegram.rst @@ -9,7 +9,7 @@ Minimal example: >>> from notifiers import get_notifier >>> telegram = get_notifier('telegram') >>> telegram.notify(message='Hi!', token='TOKEN', chat_id=1234) - + See `here ` for an example how to retrieve the ``chat_id`` for your bot. You can view the available updates you can access via the ``updates`` resource diff --git a/tests/providers/test_gitter.py b/tests/providers/test_gitter.py index aa12a7f7..dee0c9f2 100644 --- a/tests/providers/test_gitter.py +++ b/tests/providers/test_gitter.py @@ -79,7 +79,7 @@ def test_gitter_rooms_positive(self, cli_runner): @pytest.mark.online def test_gitter_rooms_with_query(self, cli_runner): - cmd = f"gitter rooms --filter notifiers/testing".split() + cmd = "gitter rooms --filter notifiers/testing".split() result = cli_runner(cmd) assert not result.exit_code assert "notifiers/testing" in result.output diff --git a/tests/test_cli.py b/tests/test_cli.py index 10998637..5be1104d 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -16,7 +16,7 @@ def test_bad_notify(self, cli_runner): cmd = f"{mock_name} notify".split() result = cli_runner(cmd) assert result.exit_code - assert getattr(result, "exception") + assert result.exception def test_notify_sanity(self, cli_runner): """Test valid notification usage""" From 80a5d04e92b7abd32772f070eacb0903dfab0284 Mon Sep 17 00:00:00 2001 From: liiight Date: Sat, 14 Nov 2020 01:58:04 +0200 Subject: [PATCH 137/137] added SchemaValidationError test --- notifiers/exceptions.py | 2 +- notifiers/models/resource.py | 4 ++-- tests/providers/test_gitter.py | 4 ++-- tests/providers/test_join.py | 4 ++-- tests/providers/test_pushover.py | 8 ++++---- tests/providers/test_statuspage.py | 4 ++-- tests/providers/test_telegram.py | 4 ++-- tests/test_core.py | 6 +++--- tests/test_schema.py | 6 ++++++ 9 files changed, 24 insertions(+), 18 deletions(-) diff --git a/notifiers/exceptions.py b/notifiers/exceptions.py index 4bd27ce7..657530ce 100644 --- a/notifiers/exceptions.py +++ b/notifiers/exceptions.py @@ -20,7 +20,7 @@ def __repr__(self): return f"" -class BadArguments(NotifierException): +class SchemaValidationError(NotifierException): """ Raised on schema data validation issues diff --git a/notifiers/models/resource.py b/notifiers/models/resource.py index 02222575..fc34c1dc 100644 --- a/notifiers/models/resource.py +++ b/notifiers/models/resource.py @@ -10,7 +10,7 @@ import requests from pydantic import ValidationError -from notifiers.exceptions import BadArguments +from notifiers.exceptions import SchemaValidationError from notifiers.models.response import Response from notifiers.models.response import ResponseStatus from notifiers.models.schema import ResourceSchema @@ -51,7 +51,7 @@ def validate_data(self, data: dict) -> T_ResourceSchema: try: return self.schema_model.parse_obj(data) except ValidationError as e: - raise BadArguments(validation_error=(str(e)), orig_excp=e) + raise SchemaValidationError(validation_error=(str(e)), orig_excp=e) from e def create_response( self, data: dict = None, response: requests.Response = None, errors: list = None diff --git a/tests/providers/test_gitter.py b/tests/providers/test_gitter.py index dee0c9f2..70e189dd 100644 --- a/tests/providers/test_gitter.py +++ b/tests/providers/test_gitter.py @@ -1,8 +1,8 @@ import pytest -from notifiers.exceptions import BadArguments from notifiers.exceptions import NotificationError from notifiers.exceptions import ResourceError +from notifiers.exceptions import SchemaValidationError provider = "gitter" @@ -42,7 +42,7 @@ def test_gitter_rooms_attribs(self, resource): assert resource.required == ["token"] def test_gitter_rooms_negative(self, resource): - with pytest.raises(BadArguments): + with pytest.raises(SchemaValidationError): resource(env_prefix="foo") def test_gitter_rooms_negative_2(self, resource): diff --git a/tests/providers/test_join.py b/tests/providers/test_join.py index 7fb819b5..eb69931e 100644 --- a/tests/providers/test_join.py +++ b/tests/providers/test_join.py @@ -1,8 +1,8 @@ import pytest -from notifiers.exceptions import BadArguments from notifiers.exceptions import NotificationError from notifiers.exceptions import ResourceError +from notifiers.exceptions import SchemaValidationError provider = "join" @@ -44,7 +44,7 @@ def test_join_devices_attribs(self, resource): } def test_join_devices_negative(self, resource): - with pytest.raises(BadArguments): + with pytest.raises(SchemaValidationError): resource(env_prefix="foo") def test_join_devices_negative_online(self, resource): diff --git a/tests/providers/test_pushover.py b/tests/providers/test_pushover.py index a5ee1572..f5070b81 100644 --- a/tests/providers/test_pushover.py +++ b/tests/providers/test_pushover.py @@ -1,7 +1,7 @@ import pytest -from notifiers.exceptions import BadArguments from notifiers.exceptions import NotificationError +from notifiers.exceptions import SchemaValidationError provider = "pushover" @@ -60,7 +60,7 @@ def test_attachment_negative(self, provider): "message": "baz", "attachment": "/foo/bar.jpg", } - with pytest.raises(BadArguments): + with pytest.raises(SchemaValidationError): provider.notify(**data) @pytest.mark.online @@ -94,7 +94,7 @@ def test_pushover_sounds_attribs(self, resource): assert resource.name == provider def test_pushover_sounds_negative(self, resource): - with pytest.raises(BadArguments): + with pytest.raises(SchemaValidationError): resource(env_prefix="foo") @pytest.mark.online @@ -124,7 +124,7 @@ def test_pushover_limits_attribs(self, resource): assert resource.name == provider def test_pushover_limits_negative(self, resource): - with pytest.raises(BadArguments): + with pytest.raises(SchemaValidationError): resource(env_prefix="foo") @pytest.mark.online diff --git a/tests/providers/test_statuspage.py b/tests/providers/test_statuspage.py index c013490e..980afbe3 100644 --- a/tests/providers/test_statuspage.py +++ b/tests/providers/test_statuspage.py @@ -6,8 +6,8 @@ import pytest import requests -from notifiers.exceptions import BadArguments from notifiers.exceptions import ResourceError +from notifiers.exceptions import SchemaValidationError from notifiers.models.response import ResponseStatus provider = "statuspage" @@ -115,7 +115,7 @@ def test_statuspage_components_attribs(self, resource): assert resource.required == ["api_key", "page_id"] def test_statuspage_components_negative(self, resource): - with pytest.raises(BadArguments): + with pytest.raises(SchemaValidationError): resource(env_prefix="foo") with pytest.raises(ResourceError, match="Could not authenticate"): diff --git a/tests/providers/test_telegram.py b/tests/providers/test_telegram.py index b8c72c31..8667bda2 100644 --- a/tests/providers/test_telegram.py +++ b/tests/providers/test_telegram.py @@ -3,8 +3,8 @@ import pytest from retry import retry -from notifiers.exceptions import BadArguments from notifiers.exceptions import NotificationError +from notifiers.exceptions import SchemaValidationError provider = "telegram" @@ -66,7 +66,7 @@ def test_telegram_updates_attribs(self, resource): assert resource.required == ["token"] def test_telegram_updates_negative(self, resource): - with pytest.raises(BadArguments): + with pytest.raises(SchemaValidationError): resource(env_prefix="foo") @pytest.mark.online diff --git a/tests/test_core.py b/tests/test_core.py index e762299f..09e2bb23 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2,9 +2,9 @@ import notifiers from notifiers import notify -from notifiers.exceptions import BadArguments from notifiers.exceptions import NoSuchNotifierError from notifiers.exceptions import NotificationError +from notifiers.exceptions import SchemaValidationError from notifiers.models.resource import Provider from notifiers.models.response import Response from notifiers.models.response import ResponseStatus @@ -61,7 +61,7 @@ def test_sanity(self, mock_provider): ) def test_schema_validation(self, data, mock_provider): """Test correct schema validations""" - with pytest.raises(BadArguments): + with pytest.raises(SchemaValidationError): mock_provider.notify(**data) def test_prepare_data(self, mock_provider): @@ -167,7 +167,7 @@ def test_resources(self, mock_provider): assert resource.required == ["key"] - with pytest.raises(BadArguments): + with pytest.raises(SchemaValidationError): resource() rsp = resource(key="fpp") diff --git a/tests/test_schema.py b/tests/test_schema.py index 88547730..c6e85cd5 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -1,6 +1,8 @@ +import pytest from hypothesis import given from hypothesis import strategies as st +from notifiers.exceptions import SchemaValidationError from notifiers.models.schema import ResourceSchema simple_type = st.one_of(st.text(), st.dates(), st.booleans()) @@ -33,3 +35,7 @@ def test_to_dict(self, mock_provider): data = {"required": "foo"} mock = mock_provider.validate_data(data) assert mock.to_dict() == {"option_with_default": "foo", "required": "foo"} + + def test_validation_error(self, mock_provider): + with pytest.raises(SchemaValidationError): + mock_provider.validate_data({})