diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index b139596c..1e01c04f 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 @@ -19,12 +19,11 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install dependencies run: | - pip install --upgrade pip setuptools + pip install setuptools==45 pip install -e . - pip install -r requirements.txt 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}} @@ -60,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 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index dc45e222..7a69d479 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,21 +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.3b0 + rev: 19.10b0 hooks: - id: black - language_version: python3.6 - 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 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 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 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 5ed5f7e0..5ca33efc 100644 --- a/notifiers/core.py +++ b/notifiers/core.py @@ -1,337 +1,15 @@ import logging -from abc import ABC -from abc import abstractmethod +from typing import List -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 - -DEFAULT_ENVIRON_PREFIX = "NOTIFIERS_" +from .models.resource import provider_registry +from .models.resource import T_Provider +from .models.response import Response log = logging.getLogger("notifiers") -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 - -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`` @@ -340,16 +18,16 @@ def get_notifier(provider_name: str, strict: bool = False) -> 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: +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/exceptions.py b/notifiers/exceptions.py index 03ed0fa7..657530ce 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""" @@ -17,7 +20,7 @@ def __repr__(self): return f"" -class BadArguments(NotifierException): +class SchemaValidationError(NotifierException): """ Raised on schema data validation issues @@ -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) @@ -34,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. @@ -61,6 +49,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/utils/schema/__init__.py b/notifiers/models/__init__.py similarity index 100% rename from notifiers/utils/schema/__init__.py rename to notifiers/models/__init__.py diff --git a/notifiers/models/resource.py b/notifiers/models/resource.py new file mode 100644 index 00000000..fc34c1dc --- /dev/null +++ b/notifiers/models/resource.py @@ -0,0 +1,193 @@ +from abc import ABC +from abc import abstractmethod +from itertools import chain +from typing import Dict +from typing import List +from typing import Optional +from typing import Type +from typing import TypeVar + +import requests +from pydantic import ValidationError + +from notifiers.exceptions import SchemaValidationError +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 + +DEFAULT_ENVIRON_PREFIX = "NOTIFIERS_" + + +class Resource(ABC): + """Base class that represent an object holding a schema and its utility methods""" + + name: str + schema_model: T_ResourceSchema + + def schema(self, by_alias: bool = True) -> dict: + """Resource's JSON schema as a dict""" + return self.schema_model.schema(by_alias=by_alias) + + def arguments(self, by_alias: bool = True) -> dict: + """Resource's arguments""" + 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") + + def validate_data(self, data: dict) -> T_ResourceSchema: + try: + return self.schema_model.parse_obj(data) + except ValidationError as 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 + ) -> 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 + """ + # todo save both original and validated data, add to the response + 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) -> 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 + """ + return dict_from_environs(prefix, self.name, self.all_fields) + + 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 + + :param data: The raw data passed by the notifiers client + :return: Processed data + """ + env_prefix = data.pop("env_prefix", DEFAULT_ENVIRON_PREFIX) + environs = self._get_environs(env_prefix) + data = merge_dicts(data, environs) + + data = self.validate_data(data) + 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: Dict[str, T_ProviderResource] = {} + + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + provider_registry[cls.name] = cls + + 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 + def base_url(self) -> str: + return "" + + @property + @abstractmethod + def site_url(self) -> str: + 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[str]: + """Return a list of names of relevant :class:`~notifiers.core.ProviderResource` objects""" + return list(self._resources.keys()) + + @abstractmethod + def _send_notification(self, data: ResourceSchema) -> Response: + """ + The core method to trigger the provider notification. Must be overridden. + + :param data: Notification data + """ + + 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 + + +T_Provider = TypeVar("T_Provider", bound=Provider) + + +provider_registry: Dict[str, Type[T_Provider]] = {} diff --git a/notifiers/models/response.py b/notifiers/models/response.py new file mode 100644 index 00000000..46504ca2 --- /dev/null +++ b/notifiers/models/response.py @@ -0,0 +1,57 @@ +from enum import Enum + +import requests + +from ..exceptions import NotificationError + + +class ResponseStatus(str, 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/models/schema.py b/notifiers/models/schema.py new file mode 100644 index 00000000..70a4cb7d --- /dev/null +++ b/notifiers/models/schema.py @@ -0,0 +1,74 @@ +import json +from typing import Any +from typing import List +from typing import Tuple +from typing import TypeVar +from typing import Union + +from pydantic import BaseModel +from pydantic import Extra +from pydantic import NameEmail + + +class ResourceSchema(BaseModel): + """The base class for Schemas""" + + _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""" + if not isinstance(value, list): + return [value] + return value + + @classmethod + def to_comma_separated(cls, values: Union[Any, List[Any]]) -> str: + """Helper method that return a comma separates string from a value""" + values = cls.to_list(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 + 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 41f0f7aa..baf5b074 100644 --- a/notifiers/providers/__init__.py +++ b/notifiers/providers/__init__.py @@ -1,7 +1,7 @@ +# flake8: noqa from . import email from . import gitter from . import gmail -from . import hipchat from . import join from . import mailgun from . import pagerduty @@ -14,22 +14,3 @@ from . import telegram from . import twilio from . import zulip - -_all_providers = { - "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, -} diff --git a/notifiers/providers/email.py b/notifiers/providers/email.py index 284775b6..5db8ad14 100644 --- a/notifiers/providers/email.py +++ b/notifiers/providers/email.py @@ -11,14 +11,63 @@ from typing import List from typing import Tuple -from ..core import Provider -from ..core import Response -from ..utils.schema.helpers import list_to_commas -from ..utils.schema.helpers import one_or_more +from pydantic import EmailStr +from pydantic import Field +from pydantic import FilePath +from pydantic import root_validator +from pydantic import validator + +from ..models.resource import Provider +from ..models.response import Response +from ..models.schema import ResourceSchema + + +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: ResourceSchema.one_or_more_of(EmailStr) = Field( + ..., description="One or more email addresses to use" + ) + 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: 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") + 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") + 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("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) -DEFAULT_SUBJECT = "New email from 'notifiers'!" -DEFAULT_FROM = f"{getpass.getuser()}@{socket.getfqdn()}" -DEFAULT_SMTP_HOST = "localhost" + @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): @@ -28,117 +77,37 @@ 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]: """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): super().__init__() 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 + self.configuration_hash = 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[str], email: EmailMessage): + def add_attachments_to_email(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(), @@ -147,42 +116,35 @@ def _add_attachments(self, attachments: List[str], 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"]) - self.configuration = self._get_configuration(data) - if data["tls"] and not data["ssl"]: + 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 = data.hash + 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"]) - - @staticmethod - def _get_configuration(data: dict) -> tuple: - return data["host"], data["port"], data.get("username") + if data.login and data.username: + self.smtp_server.login(data.username, data.password) - def _send_notification(self, data: dict) -> Response: + 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 = self._get_configuration(data) - if ( - not self.configuration - or not self.smtp_server - or self.configuration != configuration - ): + if any(connection_conditions): self._connect_to_server(data) email = self._build_email(data) - if data.get("attachments"): - self._add_attachments(data["attachments"], email) + self.add_attachments_to_email(data.attachments, email) self.smtp_server.send_message(email) except ( SMTPServerDisconnected, SMTPSenderRefused, socket.error, - OSError, - IOError, SMTPAuthenticationError, ) as e: errors = [str(e)] - return self.create_response(data, errors=errors) + return self.create_response(data.to_dict(), errors=errors) diff --git a/notifiers/providers/gitter.py b/notifiers/providers/gitter.py index ac93833a..05c46cdd 100644 --- a/notifiers/providers/gitter.py +++ b/notifiers/providers/gitter.py @@ -1,10 +1,37 @@ -from ..core import Provider -from ..core import ProviderResource -from ..core import Response +from pydantic import Field + from ..exceptions import ResourceError +from ..models.resource import Provider +from ..models.resource import ProviderResource +from ..models.response import Response +from ..models.schema import ResourceSchema from ..utils import requests +class GitterSchemaBase(ResourceSchema): + 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): + """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: """Shared attributes between :class:`~notifiers.providers.gitter.GitterRooms` and :class:`~notifiers.providers.gitter.Gitter`""" @@ -13,39 +40,21 @@ class GitterMixin: path_to_errors = "errors", "error" base_url = "https://api.gitter.im/v1/rooms" - def _get_headers(self, 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""" resource_name = "rooms" + schema_model = GitterRoomSchema + + def _get_resource(self, data: GitterRoomSchema) -> list: + 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, + headers=data.auth_header, params=params, path_to_errors=self.path_to_errors, ) @@ -58,47 +67,25 @@ 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): """Send Gitter notifications""" - 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 + def _send_notification(self, data: GitterSchema) -> Response: + url = f"{self.base_url}/{data.room_id}/chatMessages" - @property - def metadata(self) -> dict: - metadata = super().metadata - metadata["message_url"] = self.message_url - return metadata - - def _send_notification(self, data: dict) -> Response: - room_id = data.pop("room_id") - url = self.base_url + self.message_url.format(room_id=room_id) - - 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) diff --git a/notifiers/providers/gmail.py b/notifiers/providers/gmail.py index 3ec79596..59e05309 100644 --- a/notifiers/providers/gmail.py +++ b/notifiers/providers/gmail.py @@ -1,17 +1,22 @@ +from pydantic import Field + from . import email +GMAIL_SMTP_HOST = "smtp.gmail.com" + + +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") + 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 diff --git a/notifiers/providers/hipchat.py b/notifiers/providers/hipchat.py deleted file mode 100644 index 1fa6b788..00000000 --- a/notifiers/providers/hipchat.py +++ /dev/null @@ -1,398 +0,0 @@ -import copy - -from ..core import Provider -from ..core import ProviderResource -from ..core import Response -from ..exceptions import ResourceError -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/notifiers/providers/join.py b/notifiers/providers/join.py index 4c0ef3d8..c3980174 100644 --- a/notifiers/providers/join.py +++ b/notifiers/providers/join.py @@ -1,13 +1,175 @@ import json +from enum import Enum +from typing import Union import requests +from pydantic import Extra +from pydantic import Field +from pydantic import HttpUrl +from pydantic import root_validator +from pydantic import validator -from ..core import Provider -from ..core import ProviderResource -from ..core import Response from ..exceptions import ResourceError -from ..utils.schema.helpers import list_to_commas -from ..utils.schema.helpers import one_or_more +from ..models.resource import Provider +from ..models.resource import ProviderResource +from ..models.response import Response +from ..models.schema import ResourceSchema + + +class JoinGroup(str, Enum): + all_ = "group.all" + android = "group.android" + windows_10 = "group.windows10" + phone = "group.phone" + tablet = "group.tablet" + pc = "group.pc" + + +class JoinBaseSchema(ResourceSchema): + 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): + 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: 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", + ) + 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: 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", + ) + 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" + ) + 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" + ) + 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" + ) + 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, + 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" + ) + 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_format(cls, v): + return int(v) + + @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 JoinMixin: @@ -19,9 +181,10 @@ class JoinMixin: @staticmethod def _join_request(url: str, data: dict) -> tuple: # Can 't use generic requests util since API doesn't always return error status + params = data errors = None try: - response = requests.get(url, params=data) + response = requests.get(url, params=params) response.raise_for_status() rsp = response.json() if not rsp["success"]: @@ -44,18 +207,12 @@ 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, - } - - def _get_resource(self, data: dict): - url = self.base_url + self.devices_url - response, errors = self._join_request(url, data) + devices_url = "listDevices" + schema_model = JoinBaseSchema + + def _get_resource(self, data: JoinBaseSchema): + url = f"{self.base_url}/{self.devices_url}" + response, errors = self._join_request(url, data.to_dict()) if errors: raise ResourceError( errors=errors, @@ -70,135 +227,15 @@ def _get_resource(self, data: dict): class Join(JoinMixin, Provider): """Send Join notifications""" - push_url = "/sendPush" + push_url = "sendPush" site_url = "https://joaoapps.com/join/api/" _resources = {"devices": JoinDevices()} + schema_model = JoinSchema - _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 - - 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 = self.base_url + self.push_url - response, errors = self._join_request(url, data) - return self.create_response(data, response, errors) + 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) diff --git a/notifiers/providers/mailgun.py b/notifiers/providers/mailgun.py index 967fd495..07cfd5a8 100644 --- a/notifiers/providers/mailgun.py +++ b/notifiers/providers/mailgun.py @@ -1,9 +1,208 @@ import json - -from ..core import Provider -from ..core import Response +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 pydantic import conint +from pydantic import EmailStr +from pydantic import Field +from pydantic import FilePath +from pydantic import NameEmail +from pydantic import root_validator +from pydantic import validator +from typing_extensions import Literal + +from ..models.resource import Provider +from ..models.response import Response +from ..models.schema import ResourceSchema from ..utils import requests -from ..utils.schema.helpers import one_or_more + + +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(ResourceSchema): + """Send a mailgun email""" + + 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: ResourceSchema.one_or_more_of(Union[EmailStr, NameEmail]) = Field( + ..., description="Email address of the recipient(s)" + ) + cc: ResourceSchema.one_or_more_of(NameEmail) = Field( + None, description="Email address of the CC recipient(s)" + ) + bcc: ResourceSchema.one_or_more_of(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: ResourceSchema.one_or_more_of(FilePath) = Field( + None, description="File attachment(s)" + ) + inline: ResourceSchema.one_or_more_of(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: List[Ascii] = 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: ResourceSchema.one_or_more_of(Dict[str, str]) = Field( + None, + description="Add arbitrary value(s) to append a custom MIME header to the message", + ) + data: Dict[str, dict] = 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", + ) + _values_to_exclude = "domain", "api_key" + + @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") + 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 email_utils.formatdate(time.mktime(v.timetuple())) + + @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,193 +213,21 @@ 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: - url = self.base_url.format(domain=data.pop("domain")) - auth = "api", data.pop("api_key") - 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") + schema_model = MailGunSchema + def _send_notification(self, data: MailGunSchema) -> Response: + url = self.base_url.format(domain=data.domain) + files = [] + 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() 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) diff --git a/notifiers/providers/pagerduty.py b/notifiers/providers/pagerduty.py index 27e434e0..877a3556 100644 --- a/notifiers/providers/pagerduty.py +++ b/notifiers/providers/pagerduty.py @@ -1,8 +1,111 @@ -from ..core import Provider -from ..core import Response +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 ..models.resource import Provider +from ..models.response import Response +from ..models.schema import ResourceSchema from ..utils import requests +class HttpsUrl(HttpUrl): + allowed_schemes = ("https",) + + +class PagerDutyLink(ResourceSchema): + 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(ResourceSchema): + src: HttpsUrl = 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(str, Enum): + info = "info" + warning = "warning" + error = "error" + critical = "critical" + + +class PagerDutyEventAction(str, Enum): + trigger = "trigger" + acknowledge = "acknowledge" + resolve = "resolve" + + +class PagerDutyPayload(ResourceSchema): + 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" + ) + + class Config: + json_encoders = {PagerDutyPayloadSeverity: lambda v: v.value} + allow_population_by_field_name = True + + +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", + ) + 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,130 +114,11 @@ 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: - url = self.base_url + schema_model = PagerDutySchema + + def _send_notification(self, data: PagerDutySchema) -> Response: + payload = data.to_dict() response, errors = requests.post( - url, json=data, 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 32d905f1..356fa642 100644 --- a/notifiers/providers/popcornnotify.py +++ b/notifiers/providers/popcornnotify.py @@ -1,8 +1,27 @@ -from ..core import Provider -from ..core import Response +from pydantic import Field +from pydantic import validator + +from ..models.resource import Provider +from ..models.response import Response +from ..models.schema import ResourceSchema from ..utils import requests -from ..utils.schema.helpers import list_to_commas -from ..utils.schema.helpers import one_or_more + + +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: ResourceSchema.one_or_more_of(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,36 +32,11 @@ 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: + 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 7698b330..4be7a913 100644 --- a/notifiers/providers/pushbullet.py +++ b/notifiers/providers/pushbullet.py @@ -1,37 +1,107 @@ -from ..core import Provider -from ..core import ProviderResource -from ..core import Response +from enum import Enum +from functools import partial +from mimetypes import guess_type + +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.resource import Provider +from ..models.resource import ProviderResource +from ..models.response import Response +from ..models.schema import ResourceSchema from ..utils import requests +class PushbulletType(str, Enum): + note = "note" + file = "file" + link = "link" + + +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") + message: str = Field( + ..., description="Body of the push, used for all types of pushes", alias="body" + ) + 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(skip_on_failure=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`""" 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" + schema_model = PushbulletBaseSchema - _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"]) + def _get_resource(self, data: PushbulletBaseSchema) -> list: 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( @@ -48,82 +118,54 @@ 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))[0]} + response, errors = requests.post( + self.upload_request, + json=data, + headers=headers, + path_to_errors=self.path_to_errors, + ) + error = partial( + ResourceError, + errors=errors, + resource="pushbullet_file_upload", + 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: + payload = data.to_dict() + if data.file: + payload.update(self._upload_file(data.file, data.auth_headers)) response, errors = requests.post( self.base_url, - json=data, - headers=headers, + json=payload, + headers=data.auth_headers, 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/pushover.py b/notifiers/providers/pushover.py index 61416454..65784407 100644 --- a/notifiers/providers/pushover.py +++ b/notifiers/providers/pushover.py @@ -1,10 +1,130 @@ -from ..core import Provider -from ..core import ProviderResource -from ..core import Response +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.resource import Provider +from ..models.resource import ProviderResource +from ..models.response import Response +from ..models.schema import ResourceSchema from ..utils import requests -from ..utils.schema.helpers import list_to_commas -from ..utils.schema.helpers import one_or_more + + +class PushoverSound(str, 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(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)" + ) + message: str = Field(..., description="Your message") + attachment: FilePath = Field( + None, description="An image attachment to send with the message" + ) + 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", + ) + 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 priority 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 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 priority to 2", + ) + tags: PushoverBaseSchema.one_or_more_of(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) + + @root_validator + def html_or_monospace(cls, values): + if all(values.get(value) for value in ("html", "monospace")): + raise ValueError("Cannot use both 'html' and 'monospace'") + return values class PushoverMixin: @@ -13,26 +133,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 +155,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,110 +186,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 - - def _send_notification(self, data: dict) -> Response: - url = self.base_url + self.message_url - headers = {} + schema_model = PushoverSchema + + 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, - headers=headers, - 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) - - @property - def metadata(self) -> dict: - m = super().metadata - m["message_url"] = self.message_url - return m + return self.create_response(payload, response, errors) diff --git a/notifiers/providers/simplepush.py b/notifiers/providers/simplepush.py index dd5f60cb..156acbe1 100644 --- a/notifiers/providers/simplepush.py +++ b/notifiers/providers/simplepush.py @@ -1,34 +1,31 @@ -from ..core import Provider -from ..core import Response +from pydantic import Field + +from ..models.resource import Provider +from ..models.response import Response +from ..models.schema import ResourceSchema from ..utils import requests +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") + 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) diff --git a/notifiers/providers/slack.py b/notifiers/providers/slack.py deleted file mode 100644 index 0b1239d8..00000000 --- a/notifiers/providers/slack.py +++ /dev/null @@ -1,148 +0,0 @@ -from ..core import Provider -from ..core import Response -from ..utils import requests - - -class Slack(Provider): - """Send Slack webhook notifications""" - - base_url = "https://hooks.slack.com/services/" - site_url = "https://api.slack.com/incoming-webhooks" - name = "slack" - - __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 _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) - return self.create_response(data, response, errors) diff --git a/notifiers/providers/slack/__init__.py b/notifiers/providers/slack/__init__.py new file mode 100644 index 00000000..8597637c --- /dev/null +++ b/notifiers/providers/slack/__init__.py @@ -0,0 +1,65 @@ +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 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 +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", + "ActionsBlock", + "SectionBlock", + "ContextBlock", + "DividerBlock", + "FileBlock", + "ImageBlock", + "ButtonElement", + "CheckboxElement", + "DatePickerElement", + "ImageElement", + "MultiSelectBaseElement", + "MultiStaticSelectMenuElement", + "MultiSelectExternalMenuElement", + "MultiSelectUserListElement", + "MultiSelectConversationsElement", + "MultiSelectChannelsElement", + "OverflowElement", + "RadioButtonGroupElement", + "StaticSelectElement", + "ExternalSelectElement", + "SelectConversationsElement", + "SelectChannelsElement", + "SelectUsersElement", + "Text", + "Option", + "OptionGroup", + "ConfirmationDialog", + "Color", + "TextType", +] diff --git a/notifiers/providers/slack/blocks.py b/notifiers/providers/slack/blocks.py new file mode 100644 index 00000000..11801241 --- /dev/null +++ b/notifiers/providers/slack/blocks.py @@ -0,0 +1,185 @@ +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 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 +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.providers.slack.composition import _text_object_factory +from notifiers.providers.slack.composition import Text +from notifiers.providers.slack.composition import TextType + +SectionBlockElements = Union[ + ButtonElement, + CheckboxElement, + DatePickerElement, + ImageElement, + MultiStaticSelectMenuElement, + MultiSelectExternalMenuElement, + MultiSelectUserListElement, + MultiSelectConversationsElement, + MultiSelectChannelsElement, + OverflowElement, + RadioButtonGroupElement, + StaticSelectElement, + ExternalSelectElement, + SelectUsersElement, + SelectChannelsElement, +] + +ActionsBlockElements = Union[ + ButtonElement, + CheckboxElement, + DatePickerElement, + OverflowElement, + RadioButtonGroupElement, + StaticSelectElement, + ExternalSelectElement, + SelectUsersElement, + SelectChannelsElement, +] + +ContextBlockElements = Union[ImageElement, Text] + + +class BlockType(str, Enum): + section = "section" + divider = "divider" + image = "image" + actions = "actions" + context = "context" + file = "file" + + +class BaseBlock(ResourceSchema): + 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 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[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( + None, description="The text for the block, in the form of a text object" + ) + + 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_items=10, + alias="fields", + ) + accessory: SectionBlockElements = 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 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[BlockType.divider, BlockType.divider.value] = Field( + BlockType.divider, + description="The type of block. For a divider block, type will always be divider", + ) + + +class ImageBlock(BaseBlock): + """A simple image block, designed to make those cat photos really pop""" + + 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") + 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( + "ImageText", max_length=2000, type=TextType.plain_text + ) = Field(None, description="An optional title for the image") + + +class ActionsBlock(BaseBlock): + """A block that is used to hold interactive elements""" + + 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( + ..., + description="An array of interactive element objects - buttons, select menus, overflow menus, or date pickers", + max_items=5, + ) + + +class ContextBlock(BaseBlock): + """Displays message context, which can include both images and text""" + + 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( + ..., description="An array of image elements and text objects", max_items=10 + ) + + +class FileBlock(BaseBlock): + """Displays a remote 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") + source: Literal["remote"] = Field( + "remote", + description="At the moment, source will always be remote for a remote file", + ) + + +Blocks = Union[ + SectionBlock, DividerBlock, ImageBlock, ActionsBlock, ContextBlock, FileBlock, +] diff --git a/notifiers/providers/slack/composition.py b/notifiers/providers/slack/composition.py new file mode 100644 index 00000000..c0775e9a --- /dev/null +++ b/notifiers/providers/slack/composition.py @@ -0,0 +1,133 @@ +from enum import Enum +from typing import List +from typing import Type + +from pydantic import constr +from pydantic import create_model +from pydantic import Field +from pydantic import HttpUrl +from pydantic import root_validator +from typing_extensions import Literal + +from notifiers.models.schema import ResourceSchema + + +class TextType(Enum): + plain_text = "plain_text" + markdown = "mrkdwn" + + +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""" + + type: TextType = Field( + TextType.markdown, 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.", + ) + + @root_validator + def check_emoji(cls, values): + 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 = {TextType: lambda v: v.value} + + +def _text_object_factory( + model_name: str, max_length: int, type: TextType = None +) -> 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, ...) + return create_model( + model_name, + type=type_value, + text=(constr(max_length=max_length), ...), + __base__=Text, + ) + + +class Option(ResourceSchema): + """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=TextType.plain_text + ) = 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( + "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 " + "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 OptionGroup(ResourceSchema): + """Provides a way to group options in a select menu or multi-select menu""" + + label: _text_object_factory( + "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[Option] = Field( + ..., + description="An array of option objects that belong to this specific group. Maximum of 100 items", + max_items=100, + ) + + +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.""" + + title: _text_object_factory( + "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=TextType.plain_text + ) + + +class Color(str, Enum): + good = "good" + warning = "warning" + danger = "danger" diff --git a/notifiers/providers/slack/elements.py b/notifiers/providers/slack/elements.py new file mode 100644 index 00000000..2a3326ec --- /dev/null +++ b/notifiers/providers/slack/elements.py @@ -0,0 +1,299 @@ +from datetime import date +from enum import Enum +from typing import List + +from pydantic import constr +from pydantic import Field +from pydantic import HttpUrl +from pydantic import PositiveInt +from pydantic import root_validator + +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 +from notifiers.providers.slack.composition import OptionGroup +from notifiers.providers.slack.composition import TextType + + +class ElementType(str, Enum): + button = "button" + 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" + + static_select = "static_select" + external_select = "external_select" + conversations_select = "conversations_select" + users_select = "users_select" + channels_select = "channels_select" + + +class _BaseElement(ResourceSchema): + 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 " + "identify the source of the action. Should be unique among all other action_ids used " + "elsewhere by your app", + ) + + class Config: + json_encoders = {ElementType: lambda v: v.value} + + +class ButtonElementStyle(str, Enum): + primary = "primary" + danger = "danger" + default = None + + +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 = ElementType.button + 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. " + "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: ButtonElementStyle = Field( + None, + description="Decorates buttons with alternative visual color schemes. Use this option with restraint", + ) + 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 CheckboxElement(_BaseElement): + """A checkbox group that allows a user to choose multiple items from a list of possible options""" + + 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: 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 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=TextType.plain_text + ) = 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: ConfirmationDialog = Field( + None, + description="A confirm object that defines an optional confirmation dialog that appears" + " after a date is selected.", + ) + + +class ImageElement(_BaseElement): + """A plain-text summary of the image. This should not contain any markup""" + + type = ElementType.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", + ) + + +class MultiSelectBaseElement(_BaseElement): + placeholder: _text_object_factory( + "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[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: ConfirmationDialog = Field( + None, + description="A confirm object that defines an optional confirmation dialog that appears before " + "the multi-select choices are submitted", + ) + max_selected_items: PositiveInt = Field( + None, + description="Specifies the maximum number of items that can be selected in the menu", + ) + + +class MultiStaticSelectMenuElement(MultiSelectBaseElement): + """This is the simplest form of select menu, with a static list of options passed in when defining the element.""" + + type = ElementType.multi_static_select + + options: List[Option] = Field( + None, description="An array of option objects.", max_items=100 + ) + option_groups: List[OptionGroup] = Field( + None, description="An array of option group objects", max_items=100 + ) + + @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 + + +class MultiSelectExternalMenuElement(MultiSelectBaseElement): + """This menu will load its options from an external data source, allowing for a dynamic list of options.""" + + 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. " + "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 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 = 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 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 = 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 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 = 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 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 = ElementType.overflow + options: List[Option] = Field( + ..., + description="An array of option objects to display in the menu", + min_items=2, + max_items=5, + ) + confirm: ConfirmationDialog = Field( + None, + description="A confirm object that defines an optional confirmation dialog that appears after a menu " + "item is selected", + ) + + +class RadioButtonGroupElement(_BaseElement): + """A radio button group that allows a user to choose one item from a list of possible options""" + + 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: 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 StaticSelectElement(MultiStaticSelectMenuElement): + """This is the simplest form of select menu, with a static list of options passed in when defining the element""" + + type = ElementType.static_select + + +class ExternalSelectElement(MultiSelectExternalMenuElement): + """This select menu will load its options from an external data source, allowing for a dynamic list of options""" + + type = ElementType.external_select + + +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 = ElementType.conversations_select + + +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 = ElementType.channels_select + + +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 = ElementType.users_select diff --git a/notifiers/providers/slack/main.py b/notifiers/providers/slack/main.py new file mode 100644 index 00000000..7476bf9b --- /dev/null +++ b/notifiers/providers/slack/main.py @@ -0,0 +1,202 @@ +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.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 + + +class FieldObject(ResourceSchema): + 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 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.""" + + 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, + ) + color: Union[Color, 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)", + ) + 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", + min_items=1, + alias="fields", + ) + 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[Color, 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(ResourceSchema): + """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/", + ) + 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[Blocks] = Field( + None, + description="An array of layout blocks in the same format as described in the building blocks guide.", + max_items=50, + ) + attachments: List[AttachmentSchema] = 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", + ) + + +class Slack(Provider): + """Send Slack webhook notifications""" + + base_url = "https://hooks.slack.com/services/" + site_url = "https://api.slack.com/incoming-webhooks" + name = "slack" + + schema_model = SlackSchema + + def _send_notification(self, data: SlackSchema) -> Response: + payload = data.to_dict() + response, errors = requests.post(data.webhook_url, json=payload) + return self.create_response(payload, response, errors) diff --git a/notifiers/providers/statuspage.py b/notifiers/providers/statuspage.py index 04826fee..40da6e76 100644 --- a/notifiers/providers/statuspage.py +++ b/notifiers/providers/statuspage.py @@ -1,15 +1,191 @@ -from ..core import Provider -from ..core import ProviderResource -from ..core import Response -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.resource import Provider +from ..models.resource import ProviderResource +from ..models.response import Response +from ..models.schema import ResourceSchema from ..utils import requests +class Impact(str, Enum): + critical = "critical" + major = "major" + minor = "minor" + maintenance = "maintenance" + none = "none" + + +class IncidentStatus(str, 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(str, Enum): + operational = "operational" + under_maintenance = "under_maintenance" + degraded_performance = "degraded_performance" + partial_outage = "partial_outage" + major_outage = "major_outage" + empty = "" + + +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""" + + 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", + ) + _values_to_exclude = "page_id", "api_key" + + @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`""" - 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" @@ -18,25 +194,14 @@ class StatuspageMixin: class StatuspageComponents(StatuspageMixin, ProviderResource): """Return a list of Statuspage components for the page ID""" - resource_name = "components" - components_url = "components.json" + resource_name = components_url = "components" - _required = {"required": ["api_key", "page_id"]} + schema_model = StatuspageBaseSchema - _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 = self.base_url.format(page_id=data["page_id"]) + self.components_url - params = {"api_key": data.pop("api_key")} + def _get_resource(self, data: StatuspageBaseSchema) -> dict: + url = urljoin(self.base_url.format(page_id=data.page_id), self.components_url) response, errors = requests.get( - url, params=params, path_to_errors=self.path_to_errors + url, headers=data.auth_headers, path_to_errors=self.path_to_errors ) if errors: raise ResourceError( @@ -52,138 +217,18 @@ def _get_resource(self, data: dict) -> dict: class Statuspage(StatuspageMixin, Provider): """Create Statuspage incidents""" - incidents_url = "incidents.json" - + incidents_url = "incidents" _resources = {"components": StatuspageComponents()} + schema_model = StatuspageSchema - 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)", - } - - _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: + url = urljoin(self.base_url.format(page_id=data.page_id), self.incidents_url) + data_dict = data.to_dict() + payload = {"incident": data_dict} response, errors = requests.post( - url, data=data, params=params, 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, response, errors) + return self.create_response(data_dict, response, errors) diff --git a/notifiers/providers/telegram.py b/notifiers/providers/telegram.py index e5d5c7c0..a8a64454 100644 --- a/notifiers/providers/telegram.py +++ b/notifiers/providers/telegram.py @@ -1,14 +1,269 @@ -from ..core import Provider -from ..core import ProviderResource -from ..core import Response +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.resource import Provider +from ..models.resource import ProviderResource +from ..models.response import Response +from ..models.schema import ResourceSchema from ..utils import requests +class TelegramURL(HttpUrl): + allowed_schemes = "http", "https", "tg" + + +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""" + + 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(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") + 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 = [v for v in values if values.get(v) and v != "text"] + if len(values_items) > 1: + raise ValueError( + f"You must use exactly one of the optional fields, more than one were passed: {','.join(values_items)}" + ) + return values + + +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." + " 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(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""" + + 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(ResourceSchema): + """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(ResourceSchema): + """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(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 + (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(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 + 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(str, Enum): + markdown = "Markdown" + html = "HTML" + markdown_v2 = "MarkdownV2" + + +class TelegramBaseSchema(ResourceSchema): + token: str = Field(..., description="Bot token") + + +class TelegramSchema(TelegramBaseSchema): + """Telegram message sending schema""" + + message: str = Field( + ..., + 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, " + "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", + ) + + _values_to_exclude = ("token",) + + class Config: + json_encoders = {ParseMode: lambda v: v.value} + + 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",) @@ -17,18 +272,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 +292,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() 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) diff --git a/notifiers/providers/twilio.py b/notifiers/providers/twilio.py index 1ec700ab..aec579f2 100644 --- a/notifiers/providers/twilio.py +++ b/notifiers/providers/twilio.py @@ -1,8 +1,137 @@ -from ..core import Provider -from ..core import Response +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.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 +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(ResourceSchema): + """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", + alias="From", + ) + 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: 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 " + "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, + ) + + 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")): + 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""" @@ -12,105 +141,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() 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) diff --git a/notifiers/providers/zulip.py b/notifiers/providers/zulip.py index c9a99065..4403f5a2 100644 --- a/notifiers/providers/zulip.py +++ b/notifiers/providers/zulip.py @@ -1,86 +1,93 @@ -from ..core import Provider -from ..core import Response -from ..exceptions import NotifierException +from enum import Enum +from typing import Union + +from pydantic import constr +from pydantic import EmailStr +from pydantic import Field +from pydantic import HttpUrl +from pydantic import root_validator +from pydantic import validator + +from ..models.resource import Provider +from ..models.response import Response +from ..models.schema import ResourceSchema from ..utils import requests +class MessageType(str, Enum): + private = "private" + stream = "stream" + + +class ZulipSchema(ResourceSchema): + """Send a stream or a private message""" + + api_key: str = Field(..., description="User API Key") + 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, + 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: 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", + ) + 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 ResourceSchema.to_comma_separated(v) + + _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["url"] or f"https://{values['domain']}.zulipchat.com" + url = f"{base_url}/api/v1/messages" + values["server_url"] = url + + 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")) + schema_model = ZulipSchema + + def _send_notification(self, data: ZulipSchema) -> Response: + auth = data.email, data.api_key + payload = data.to_dict() response, errors = requests.post( - url, data=data, auth=auth, path_to_errors=self.path_to_errors + data.server_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) diff --git a/notifiers/utils/helpers.py b/notifiers/utils/helpers.py index e351956f..a05fb84d 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 pathlib import Path +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 @@ -29,12 +16,11 @@ 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 -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] @@ -44,13 +30,27 @@ 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) + 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 = {} + + # 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: - environ = f"{prefix}{name}_{arg}".upper() - if os.environ.get(environ): - environs[arg] = os.environ[environ] - return environs + 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) + if value: + data[arg] = value + log.debug("Returning data %s from environment variables", data) + return data def snake_to_camel_case(value: str) -> str: @@ -60,17 +60,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("_")) - - -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() diff --git a/notifiers/utils/requests.py b/notifiers/utils/requests.py index e3ca038f..87bd2755 100644 --- a/notifiers/utils/requests.py +++ b/notifiers/utils/requests.py @@ -1,5 +1,8 @@ import json import logging +from pathlib import Path +from typing import List +from typing import Union import requests @@ -11,7 +14,7 @@ class RequestsHelper: @classmethod def request( - self, + cls, url: str, method: str, raise_for_status: bool = True, @@ -30,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(), @@ -80,19 +83,18 @@ def post(url: str, *args, **kwargs) -> tuple: def file_list_for_request( - list_of_paths: list, 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, open(file, mode="rb"), 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(), mimetype)) for file in paths] + return [(key_name, (file.name, file.read_bytes())) for file in paths] 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/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/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 diff --git a/requirements.txt b/requirements.txt index 0cf2455c..fba94b9d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,6 @@ 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 +pydantic[email] +typing_extensions \ No newline at end of file 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 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 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/conftest.py b/tests/conftest.py index 578e2a9f..2e772c85 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -6,73 +6,64 @@ 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 -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.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 +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 -log = logging.getLogger(__name__) +log = logging.getLogger("notifiers") 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: StrictStr + option_with_default = "foo" + message: str = None + + @validator("not_required", whole=True) + def csv(cls, v): + return cls.to_comma_separated(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, - } site_url = "https://www.mock.com" + schema_model = MockProviderSchema - @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 + def _send_notification(self, data: MockProviderSchema): + return Response( + status=ResponseStatus.SUCCESS, provider=self.name, data=data.to_dict() + ) @property def resources(self): @@ -86,39 +77,10 @@ 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() -@pytest.fixture -def bad_provider(): - """Returns an unimplemented :class:`notifiers.core.Provider` class for testing""" - - class BadProvider(Provider): - pass - - 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") +@pytest.fixture(scope="module") def provider(request): name = getattr(request.module, "provider", None) if not name: @@ -142,6 +104,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") @@ -155,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() @@ -170,15 +133,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/providers/test_generic_provider_tests.py b/tests/providers/test_generic_provider_tests.py new file mode 100644 index 00000000..9343840a --- /dev/null +++ b/tests/providers/test_generic_provider_tests.py @@ -0,0 +1,14 @@ +import pytest + +from notifiers.models.resource import provider_registry + + +@pytest.mark.parametrize("provider", provider_registry.values()) +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, + } diff --git a/tests/providers/test_gitter.py b/tests/providers/test_gitter.py index 2c232bcb..70e189dd 100644 --- a/tests/providers/test_gitter.py +++ b/tests/providers/test_gitter.py @@ -1,40 +1,17 @@ import pytest -from notifiers.exceptions import BadArguments from notifiers.exceptions import NotificationError from notifiers.exceptions import ResourceError +from notifiers.exceptions import SchemaValidationError provider = "gitter" 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", - [ - ({}, "message"), - ({"message": "foo"}, "token"), - ({"message": "foo", "token": "bar"}, "room_id"), - ], - ) - def test_missing_required(self, provider, data, message): - 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_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 @@ -48,8 +25,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,20 +38,11 @@ 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): + with pytest.raises(SchemaValidationError): resource(env_prefix="foo") def test_gitter_rooms_negative_2(self, resource): @@ -112,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/providers/test_gmail.py b/tests/providers/test_gmail.py index 481c3871..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,22 +8,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", [({}, "message"), ({"message": "foo"}, "to")] - ) - def test_gmail_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_smtp_sanity(self, provider, test_message): """using Gmail SMTP""" @@ -37,19 +20,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( 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 diff --git a/tests/providers/test_join.py b/tests/providers/test_join.py index e0b9960b..eb69931e 100644 --- a/tests/providers/test_join.py +++ b/tests/providers/test_join.py @@ -1,32 +1,13 @@ import pytest -from notifiers.exceptions import BadArguments from notifiers.exceptions import NotificationError from notifiers.exceptions import ResourceError +from notifiers.exceptions import SchemaValidationError provider = "join" 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")] - ) - 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_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): @@ -47,15 +28,23 @@ class TestJoinDevices: resource = "devices" def test_join_devices_attribs(self, resource): - assert resource.schema == { - "type": "object", - "properties": {"apikey": {"type": "string", "title": "user API key"}}, + assert resource.schema() == { "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): - with pytest.raises(BadArguments): + with pytest.raises(SchemaValidationError): resource(env_prefix="foo") def test_join_devices_negative_online(self, resource): @@ -77,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_mailgun.py b/tests/providers/test_mailgun.py index 1986cd60..30eebb56 100644 --- a/tests/providers/test_mailgun.py +++ b/tests/providers/test_mailgun.py @@ -1,41 +1,13 @@ import datetime -import time -from email import utils import pytest -from notifiers.core import FAILURE_STATUS -from notifiers.exceptions import BadArguments +from notifiers.models.response import ResponseStatus provider = "mailgun" 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", - [ - ({}, "to"), - ({"to": "foo"}, "domain"), - ({"to": "foo", "domain": "bla"}, "api_key"), - ({"to": "foo", "domain": "bla", "api_key": "bla"}, "from"), - ( - {"to": "foo", "domain": "bla", "api_key": "bla", "from": "bbb"}, - "message", - ), - ], - ) - def test_mailgun_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.online def test_mailgun_sanity(self, provider, test_message): provider.notify(message=test_message, raise_on_errors=True) @@ -50,7 +22,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 +30,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, @@ -80,5 +51,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 diff --git a/tests/providers/test_pagerduty.py b/tests/providers/test_pagerduty.py index 980fed64..8fceac86 100644 --- a/tests/providers/test_pagerduty.py +++ b/tests/providers/test_pagerduty.py @@ -2,53 +2,15 @@ import pytest -from notifiers.exceptions import BadArguments - provider = "pagerduty" 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", - [ - ({}, "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() @@ -71,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 091ac7f7..def7bcf4 100644 --- a/tests/providers/test_popcornnotify.py +++ b/tests/providers/test_popcornnotify.py @@ -1,34 +1,12 @@ 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" 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", - [ - ({}, "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) as e: - 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?") def test_popcornnotify_sanity(self, provider, test_message): @@ -38,7 +16,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_pushbullet.py b/tests/providers/test_pushbullet.py index d8c02720..19ad5442 100644 --- a/tests/providers/test_pushbullet.py +++ b/tests/providers/test_pushbullet.py @@ -2,29 +2,11 @@ import pytest -from notifiers.exceptions import BadArguments - provider = "pushbullet" @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")] - ) - 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 7be5844b..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" @@ -12,28 +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", - [ - ({}, "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")] ) @@ -82,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 @@ -98,18 +76,25 @@ class TestPushoverSoundsResource: resource = "sounds" def test_pushover_sounds_attribs(self, resource): - assert resource.schema == { - "type": "object", + assert resource.schema() == { + "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 def test_pushover_sounds_negative(self, resource): - with pytest.raises(BadArguments): + with pytest.raises(SchemaValidationError): resource(env_prefix="foo") @pytest.mark.online @@ -121,18 +106,25 @@ class TestPushoverLimitsResource: resource = "limits" def test_pushover_limits_attribs(self, resource): - assert resource.schema == { - "type": "object", + assert resource.schema() == { + "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 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_simplepush.py b/tests/providers/test_simplepush.py index 733772a1..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,22 +9,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")] - ) - 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_slack.py b/tests/providers/test_slack.py index 84f9d1b5..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} @@ -25,11 +18,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 +66,4 @@ def test_all_options(self, provider): }, ], } - rsp = provider.notify(**data) - rsp.raise_on_errors() + provider.notify(**data, raise_on_errors=True) diff --git a/tests/providers/test_smtp.py b/tests/providers/test_smtp.py index 35c499d9..cfe70701 100644 --- a/tests/providers/test_smtp.py +++ b/tests/providers/test_smtp.py @@ -1,8 +1,8 @@ from email.message import EmailMessage +from pathlib import Path import pytest -from notifiers.exceptions import BadArguments from notifiers.exceptions import NotificationError provider = "email" @@ -11,22 +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")] - ) - 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", @@ -85,17 +69,22 @@ 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" + smtp_base.mkdir() + + 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(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" diff --git a/tests/providers/test_statuspage.py b/tests/providers/test_statuspage.py index 74e33c80..980afbe3 100644 --- a/tests/providers/test_statuspage.py +++ b/tests/providers/test_statuspage.py @@ -6,13 +6,13 @@ import pytest import requests -from notifiers.core import FAILURE_STATUS -from notifiers.exceptions import BadArguments from notifiers.exceptions import ResourceError +from notifiers.exceptions import SchemaValidationError +from notifiers.models.response import ResponseStatus provider = "statuspage" -log = logging.getLogger("statuspage") +log = logging.getLogger("notifiers") @pytest.fixture(autouse=True, scope="module") @@ -34,66 +34,10 @@ 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", - [ - ({}, "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", - [ - ( - { - "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) - assert rsp.status == FAILURE_STATUS + assert rsp.status is ResponseStatus.FAILURE assert "Could not authenticate" in rsp.errors @pytest.mark.online @@ -106,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, } @@ -116,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": ( @@ -136,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(), } ), ], @@ -151,21 +91,31 @@ 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": { - "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): + 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 ada124cd..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" @@ -12,27 +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", - [ - ({}, "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: @@ -56,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, @@ -69,17 +48,25 @@ class TestTelegramResources: resource = "updates" def test_telegram_updates_attribs(self, resource): - assert resource.schema == { + 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 - assert resource.required == {"required": ["token"]} + 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 @@ -100,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/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..2c7ae69c 100644 --- a/tests/providers/test_zulip.py +++ b/tests/providers/test_zulip.py @@ -2,53 +2,19 @@ import pytest -from notifiers.exceptions import BadArguments from notifiers.exceptions import NotifierException provider = "zulip" 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", - [ - ( - {"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 = { "to": "general", "message": test_message, "domain": "notifiers", - "subject": "test", + "topic": "test", } rsp = provider.notify(**data) rsp.raise_on_errors() @@ -69,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_") @@ -84,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 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""" diff --git a/tests/test_core.py b/tests/test_core.py index 42872190..09e2bb23 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -2,19 +2,18 @@ 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.exceptions import SchemaValidationError +from notifiers.models.resource import Provider +from notifiers.models.response import Response +from notifiers.models.response import ResponseStatus 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""" @@ -23,36 +22,34 @@ 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": { - "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"] rsp = mock_provider.notify(**self.valid_data) assert isinstance(rsp, Response) assert not rsp.errors assert rsp.raise_on_errors() is None assert ( repr(rsp) - == f"" + == f"" ) - assert repr(mock_provider) == "" + assert repr(mock_provider) == "" @pytest.mark.parametrize( "data", @@ -64,14 +61,9 @@ 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_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) @@ -79,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): @@ -114,35 +107,27 @@ 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_" + prefix = "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( 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 == SUCCESS_STATUS + assert rsp.status is ResponseStatus.SUCCESS assert rsp.data["required"] == "bar" def test_resources(self, mock_provider): @@ -160,28 +145,38 @@ 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", "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): + with pytest.raises(SchemaValidationError): 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", 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_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)", ) diff --git a/tests/test_schema.py b/tests/test_schema.py new file mode 100644 index 00000000..c6e85cd5 --- /dev/null +++ b/tests/test_schema.py @@ -0,0 +1,41 @@ +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()) + +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"} + + def test_validation_error(self, mock_provider): + with pytest.raises(SchemaValidationError): + mock_provider.validate_data({}) diff --git a/tests/test_utils.py b/tests/test_utils.py index c09e9785..17de8539 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,32 +1,22 @@ import pytest +from pydantic import Field +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 -from notifiers.utils.helpers import text_to_bool -from notifiers.utils.helpers import valid_file from notifiers.utils.requests import file_list_for_request -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 +class TypeTest(ResourceSchema): + string = "" + integer = 0 + float_ = Field(0.1, alias="floatAlias") + bool_ = Field(True, alias="boolAlias") + bytes = b"" + +class TestHelpers: @pytest.mark.parametrize( "target_dict, merge_dict, result", [ @@ -43,7 +33,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 @@ -58,25 +48,12 @@ 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) + def test_file_list_for_request(self, tmp_path): + file_1 = tmp_path / "file_1" + file_2 = tmp_path / "file_2" - 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, tmpdir): - file_1 = tmpdir.join("file_1") - file_2 = tmpdir.join("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 @@ -85,3 +62,34 @@ def test_file_list_for_request(self, tmpdir): 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" + ) + + 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)