simplified validation (#91)

This commit is contained in:
Simone Robutti 2021-10-24 21:32:28 +02:00 committed by GitHub
parent 6430de4a84
commit e16dd19a7c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 130 additions and 103 deletions

View File

@ -76,9 +76,12 @@ class AbstractPlatform(ABC, LoggerMixin, ConfLoaderMixin):
# the second the name of its service (ie: 'facebook', 'telegram') # the second the name of its service (ie: 'facebook', 'telegram')
def __repr__(self): def __repr__(self):
return type(self).__name__ return self.name
__str__ = __repr__ @property
@abstractmethod
def name(self):
pass
@abstractmethod @abstractmethod
def _send(self, message: str): def _send(self, message: str):
@ -99,13 +102,6 @@ class AbstractPlatform(ABC, LoggerMixin, ConfLoaderMixin):
def _validate_response(self, response): def _validate_response(self, response):
raise NotImplementedError # pragma: no cover raise NotImplementedError # pragma: no cover
def are_credentials_valid(self) -> bool:
try:
self.validate_credentials()
except PublisherError:
return False
return True
@abstractmethod @abstractmethod
def validate_credentials(self) -> None: def validate_credentials(self) -> None:
""" """
@ -146,14 +142,6 @@ class AbstractEventFormatter(LoggerMixin, ConfLoaderMixin):
template_path = self.conf.msg_template_path or self.default_template_path template_path = self.conf.msg_template_path or self.default_template_path
return JINJA_ENV.get_template(template_path) return JINJA_ENV.get_template(template_path)
def is_message_valid(self, event: MobilizonEvent) -> bool:
# TODO: this thing swallows exception messages. It should be handled differently
try:
self.validate_message(self.get_message_from_event(event))
except PublisherError:
return False
return True
@abstractmethod @abstractmethod
def validate_message(self, message: str) -> None: def validate_message(self, message: str) -> None:
""" """
@ -163,13 +151,6 @@ class AbstractEventFormatter(LoggerMixin, ConfLoaderMixin):
""" """
raise NotImplementedError # pragma: no cover raise NotImplementedError # pragma: no cover
def is_event_valid(self, event) -> bool:
try:
self.validate_event(event)
except PublisherError:
return False
return True
def get_recap_header(self): def get_recap_header(self):
template_path = ( template_path = (
self.conf.recap_header_template_path self.conf.recap_header_template_path
@ -184,6 +165,9 @@ class AbstractEventFormatter(LoggerMixin, ConfLoaderMixin):
return JINJA_ENV.get_template(template_path) return JINJA_ENV.get_template(template_path)
def get_recap_fragment(self, event: MobilizonEvent) -> str: def get_recap_fragment(self, event: MobilizonEvent) -> str:
"""
Retrieves the fragment that describes a single event inside the event recap.
"""
event = self._preprocess_event(event) event = self._preprocess_event(event)
return event.format(self.get_recap_fragment_template()) return event.format(self.get_recap_fragment_template())

View File

@ -1,7 +1,6 @@
import logging import logging
from dataclasses import dataclass from dataclasses import dataclass
from typing import List, Optional from typing import List, Optional
from uuid import UUID
from mobilizon_reshare.models.publication import PublicationStatus from mobilizon_reshare.models.publication import PublicationStatus
from mobilizon_reshare.publishers import get_active_notifiers from mobilizon_reshare.publishers import get_active_notifiers
@ -30,13 +29,17 @@ class BasePublicationReport:
@dataclass @dataclass
class EventPublicationReport(BasePublicationReport): class EventPublicationReport(BasePublicationReport):
publication_id: UUID publication: EventPublication
def get_failure_message(self): def get_failure_message(self):
if not self.reason:
logger.error("Report of failure without reason.", exc_info=True)
return ( return (
f"Publication {self.publication_id } failed with status: {self.status}.\n" f"Publication {self.publication.id} failed with status: {self.status}.\n"
f"Reason: {self.reason}" f"Reason: {self.reason}\n"
f"Publisher: {self.publication.publisher.name}"
) )
@ -72,9 +75,7 @@ class PublisherCoordinator:
def _make_successful_report(self, failed_ids): def _make_successful_report(self, failed_ids):
return [ return [
EventPublicationReport( EventPublicationReport(
status=PublicationStatus.COMPLETED, status=PublicationStatus.COMPLETED, reason="", publication=publication,
reason="",
publication_id=publication.id,
) )
for publication in self.publications for publication in self.publications
if publication.id not in failed_ids if publication.id not in failed_ids
@ -93,7 +94,7 @@ class PublisherCoordinator:
reports.append( reports.append(
EventPublicationReport( EventPublicationReport(
status=PublicationStatus.COMPLETED, status=PublicationStatus.COMPLETED,
publication_id=publication.id, publication=publication,
reason=None, reason=None,
) )
) )
@ -102,7 +103,7 @@ class PublisherCoordinator:
EventPublicationReport( EventPublicationReport(
status=PublicationStatus.FAILED, status=PublicationStatus.FAILED,
reason=str(e), reason=str(e),
publication_id=publication.id, publication=publication,
) )
) )
@ -110,25 +111,37 @@ class PublisherCoordinator:
publications=self.publications, reports=reports publications=self.publications, reports=reports
) )
def _safe_run(self, reasons, f, *args, **kwargs):
try:
f(*args, **kwargs)
return reasons
except Exception as e:
logger.error(str(e))
return reasons + [str(e)]
def _validate(self): def _validate(self):
errors = [] errors = []
for publication in self.publications: for publication in self.publications:
reasons = []
reasons = self._safe_run(
reasons, publication.publisher.validate_credentials,
)
reasons = self._safe_run(
reasons, publication.formatter.validate_event, publication.event
)
reasons = self._safe_run(
reasons,
publication.formatter.validate_message,
publication.formatter.get_message_from_event(publication.event),
)
reason = [] if len(reasons) > 0:
if not publication.publisher.are_credentials_valid():
reason.append("Invalid credentials")
if not publication.formatter.is_event_valid(publication.event):
reason.append("Invalid event")
if not publication.formatter.is_message_valid(publication.event):
reason.append("Invalid message")
if len(reason) > 0:
errors.append( errors.append(
EventPublicationReport( EventPublicationReport(
status=PublicationStatus.FAILED, status=PublicationStatus.FAILED,
reason=", ".join(reason), reason=", ".join(reasons),
publication_id=publication.id, publication=publication,
) )
) )

View File

@ -32,9 +32,9 @@ class InvalidSettings(PublisherError):
""" Publisher settings are either missing or badly configured """ """ Publisher settings are either missing or badly configured """
class HTTPResponseError(PublisherError):
""" Response received with an HTTP error status code"""
class ZulipError(PublisherError): class ZulipError(PublisherError):
""" Publisher receives an error response from Zulip""" """ Publisher receives an error response from Zulip"""
class HTTPResponseError(PublisherError):
""" Publisher receives a HTTP error"""

View File

@ -1,6 +1,6 @@
import pkg_resources
from urllib.parse import urljoin from urllib.parse import urljoin
import pkg_resources
import requests import requests
from requests import Response from requests import Response
@ -13,8 +13,8 @@ from mobilizon_reshare.publishers.exceptions import (
InvalidBot, InvalidBot,
InvalidEvent, InvalidEvent,
InvalidResponse, InvalidResponse,
PublisherError,
HTTPResponseError, HTTPResponseError,
InvalidMessage,
) )
@ -40,7 +40,7 @@ class MastodonFormatter(AbstractEventFormatter):
def validate_message(self, message) -> None: def validate_message(self, message) -> None:
if len(message.encode("utf-8")) >= self.conf.toot_length: if len(message.encode("utf-8")) >= self.conf.toot_length:
raise PublisherError("Message is too long") raise InvalidMessage("Message is too long")
class MastodonPlatform(AbstractPlatform): class MastodonPlatform(AbstractPlatform):
@ -50,6 +50,7 @@ class MastodonPlatform(AbstractPlatform):
_conf = ("publisher", "mastodon") _conf = ("publisher", "mastodon")
api_uri = "api/v1/" api_uri = "api/v1/"
name = "mastodon"
def _send(self, message: str) -> Response: def _send(self, message: str) -> Response:
""" """
@ -58,10 +59,7 @@ class MastodonPlatform(AbstractPlatform):
return requests.post( return requests.post(
url=urljoin(self.conf.instance, self.api_uri) + "statuses", url=urljoin(self.conf.instance, self.api_uri) + "statuses",
headers={"Authorization": f"Bearer {self.conf.token}"}, headers={"Authorization": f"Bearer {self.conf.token}"},
data={ data={"status": message, "visibility": "public"},
"status": message,
"visibility": "public",
},
) )
def validate_credentials(self): def validate_credentials(self):
@ -85,8 +83,7 @@ class MastodonPlatform(AbstractPlatform):
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
self._log_debug(str(res)) self._log_debug(str(res))
self._log_error( self._log_error(
str(e), str(e), raise_error=HTTPResponseError,
raise_error=HTTPResponseError,
) )
try: try:

View File

@ -19,6 +19,11 @@ from mobilizon_reshare.publishers.platforms.zulip import (
ZulipNotifier, ZulipNotifier,
) )
"""
This module is required to have an explicit mapping between platform names and the classes implementing those platforms.
It could be refactored in a different pattern but this way makes it more explicit and linear. Eventually this could be
turned into a plugin system with a plugin for each platform."""
name_to_publisher_class = { name_to_publisher_class = {
"mastodon": MastodonPublisher, "mastodon": MastodonPublisher,
"telegram": TelegramPublisher, "telegram": TelegramPublisher,

View File

@ -14,8 +14,7 @@ from mobilizon_reshare.publishers.exceptions import (
InvalidBot, InvalidBot,
InvalidEvent, InvalidEvent,
InvalidResponse, InvalidResponse,
PublisherError, InvalidMessage,
HTTPResponseError,
) )
@ -72,7 +71,7 @@ class TelegramFormatter(AbstractEventFormatter):
def validate_message(self, message: str) -> None: def validate_message(self, message: str) -> None:
if len(message) >= 4096: if len(message) >= 4096:
raise PublisherError("Message is too long") raise InvalidMessage("Message is too long")
def _preprocess_event(self, event: MobilizonEvent): def _preprocess_event(self, event: MobilizonEvent):
event.description = html_to_markdown(event.description) event.description = html_to_markdown(event.description)
@ -85,6 +84,8 @@ class TelegramPlatform(AbstractPlatform):
Telegram publisher class. Telegram publisher class.
""" """
name = "telegram"
def _preprocess_message(self, message: str): def _preprocess_message(self, message: str):
return TelegramFormatter.escape_message(message) return TelegramFormatter.escape_message(message)
@ -109,12 +110,11 @@ class TelegramPlatform(AbstractPlatform):
def _validate_response(self, res): def _validate_response(self, res):
try: try:
res.raise_for_status() res.raise_for_status()
except requests.exceptions.HTTPError as e: except requests.exceptions.HTTPError as e:
self._log_debug(str(res))
self._log_error( self._log_error(
str(e), f"Server returned invalid data: {str(e)}", raise_error=InvalidResponse,
raise_error=HTTPResponseError,
) )
try: try:

View File

@ -47,6 +47,7 @@ class TwitterPlatform(AbstractPlatform):
""" """
_conf = ("publisher", "twitter") _conf = ("publisher", "twitter")
name = "twitter"
def _get_api(self): def _get_api(self):
@ -62,7 +63,7 @@ class TwitterPlatform(AbstractPlatform):
try: try:
return self._get_api().update_status(message) return self._get_api().update_status(message)
except TweepyException as e: except TweepyException as e:
raise PublisherError from e raise PublisherError(e.args[0])
def validate_credentials(self): def validate_credentials(self):
if not self._get_api().verify_credentials(): if not self._get_api().verify_credentials():

View File

@ -14,7 +14,7 @@ from mobilizon_reshare.publishers.exceptions import (
InvalidEvent, InvalidEvent,
InvalidResponse, InvalidResponse,
ZulipError, ZulipError,
PublisherError, InvalidMessage,
) )
@ -40,7 +40,7 @@ class ZulipFormatter(AbstractEventFormatter):
def validate_message(self, message) -> None: def validate_message(self, message) -> None:
if len(message.encode("utf-8")) >= 10000: if len(message.encode("utf-8")) >= 10000:
raise PublisherError("Message is too long") raise InvalidMessage("Message is too long")
def _preprocess_event(self, event: MobilizonEvent): def _preprocess_event(self, event: MobilizonEvent):
event.description = html_to_markdown(event.description) event.description = html_to_markdown(event.description)
@ -56,6 +56,7 @@ class ZulipPlatform(AbstractPlatform):
_conf = ("publisher", "zulip") _conf = ("publisher", "zulip")
api_uri = "https://zulip.twc-italia.org/api/v1/" api_uri = "https://zulip.twc-italia.org/api/v1/"
name = "zulip"
def _send_private(self, message: str) -> Response: def _send_private(self, message: str) -> Response:
""" """

View File

@ -201,7 +201,7 @@ async def save_publication_report(
publications: Dict[UUID, Publication], publications: Dict[UUID, Publication],
) -> None: ) -> None:
for publication_report in coordinator_report.reports: for publication_report in coordinator_report.reports:
publication_id = publication_report.publication_id publication_id = publication_report.publication.id
publications[publication_id].status = publication_report.status publications[publication_id].status = publication_report.status
publications[publication_id].reason = publication_report.reason publications[publication_id].reason = publication_report.reason
publications[publication_id].timestamp = arrow.now().datetime publications[publication_id].timestamp = arrow.now().datetime

View File

@ -65,13 +65,13 @@ def message_collector():
def mock_formatter_invalid(): def mock_formatter_invalid():
class MockFormatter(AbstractEventFormatter): class MockFormatter(AbstractEventFormatter):
def validate_event(self, event) -> None: def validate_event(self, event) -> None:
raise PublisherError("Invalid event") raise PublisherError("Invalid event error")
def get_message_from_event(self, event) -> str: def get_message_from_event(self, event) -> str:
return "" return ""
def validate_message(self, event) -> None: def validate_message(self, event) -> None:
raise PublisherError("Invalid message") raise PublisherError("Invalid message error")
return MockFormatter() return MockFormatter()
@ -79,8 +79,10 @@ def mock_formatter_invalid():
@pytest.fixture @pytest.fixture
def mock_publisher_valid(message_collector): def mock_publisher_valid(message_collector):
class MockPublisher(AbstractPlatform): class MockPublisher(AbstractPlatform):
name = "mock"
def _send(self, message): def _send(self, message):
message_collector.collect_message(message) message_collector.append(message)
def _validate_response(self, response): def _validate_response(self, response):
pass pass
@ -94,15 +96,17 @@ def mock_publisher_valid(message_collector):
@pytest.fixture @pytest.fixture
def mock_publisher_invalid(message_collector): def mock_publisher_invalid(message_collector):
class MockPublisher(AbstractPlatform): class MockPublisher(AbstractPlatform):
def _send(self, message):
message_collector.collect_message(message) name = "mock"
def _send(self, message):
message_collector.append(message)
def _validate_response(self, response): def _validate_response(self, response):
return InvalidResponse("error") return InvalidResponse("response error")
def validate_credentials(self) -> None: def validate_credentials(self) -> None:
raise PublisherError("error") raise PublisherError("credentials error")
return MockPublisher() return MockPublisher()
@ -110,8 +114,11 @@ def mock_publisher_invalid(message_collector):
@pytest.fixture @pytest.fixture
def mock_publisher_invalid_response(message_collector): def mock_publisher_invalid_response(message_collector):
class MockPublisher(AbstractPlatform): class MockPublisher(AbstractPlatform):
name = "mock"
def _send(self, message): def _send(self, message):
message_collector.collect_message(message) message_collector.append(message)
def _validate_response(self, response): def _validate_response(self, response):
raise InvalidResponse("Invalid response") raise InvalidResponse("Invalid response")

View File

@ -1,14 +0,0 @@
def test_is_event_valid(mock_formatter_valid, event):
assert mock_formatter_valid.is_event_valid(event)
def test_is_event_valid_false(mock_formatter_invalid, event):
assert not mock_formatter_invalid.is_event_valid(event)
def test_is_message_valid(mock_formatter_valid, event):
assert mock_formatter_valid.is_message_valid(event)
def test_is_message_valid_false(mock_formatter_invalid, event):
assert not mock_formatter_invalid.is_message_valid(event)

View File

@ -33,7 +33,7 @@ def test_publication_report_successful(statuses, successful):
reports = [] reports = []
for i, status in enumerate(statuses): for i, status in enumerate(statuses):
reports.append( reports.append(
EventPublicationReport(reason=None, publication_id=None, status=status) EventPublicationReport(reason=None, publication=None, status=status)
) )
assert ( assert (
PublisherCoordinatorReport(publications=[], reports=reports).successful PublisherCoordinatorReport(publications=[], reports=reports).successful
@ -116,7 +116,7 @@ async def test_publication_coordinator_run_failure(
assert not report.successful assert not report.successful
assert ( assert (
list(report.reports)[0].reason list(report.reports)[0].reason
== "Invalid credentials, Invalid event, Invalid message" == "credentials error, Invalid event error, Invalid message error"
) )
@ -142,7 +142,9 @@ async def test_notifier_coordinator_publication_failed(mock_publisher_valid):
report = EventPublicationReport( report = EventPublicationReport(
status=PublicationStatus.FAILED, status=PublicationStatus.FAILED,
reason="some failure", reason="some failure",
publication_id=UUID(int=1), publication=EventPublication(
id=UUID(int=4), publisher=mock_publisher_valid, formatter=None, event=None
),
) )
coordinator = PublicationFailureNotifiersCoordinator( coordinator = PublicationFailureNotifiersCoordinator(
report, [mock_publisher_valid, mock_publisher_valid] report, [mock_publisher_valid, mock_publisher_valid]

View File

@ -5,6 +5,7 @@ from mobilizon_reshare.publishers.exceptions import (
InvalidEvent, InvalidEvent,
InvalidResponse, InvalidResponse,
HTTPResponseError, HTTPResponseError,
InvalidMessage,
) )
from mobilizon_reshare.publishers.platforms.mastodon import ( from mobilizon_reshare.publishers.platforms.mastodon import (
MastodonFormatter, MastodonFormatter,
@ -15,13 +16,16 @@ from mobilizon_reshare.publishers.platforms.mastodon import (
def test_message_length_success(event): def test_message_length_success(event):
message = "a" * 200 message = "a" * 200
event.name = message event.name = message
assert MastodonFormatter().is_message_valid(event) message = MastodonFormatter().get_message_from_event(event)
MastodonFormatter().validate_message(message)
def test_message_length_failure(event): def test_message_length_failure(event):
message = "a" * 500 message = "a" * 500
event.name = message event.name = message
assert not MastodonFormatter().is_message_valid(event) message = MastodonFormatter().get_message_from_event(event)
with pytest.raises(InvalidMessage):
MastodonFormatter().validate_message(message)
def test_event_validation(event): def test_event_validation(event):

View File

@ -1,7 +1,11 @@
import pytest import pytest
import requests import requests
from mobilizon_reshare.publishers.exceptions import InvalidEvent, InvalidResponse, HTTPResponseError from mobilizon_reshare.publishers.exceptions import (
InvalidEvent,
InvalidResponse,
InvalidMessage,
)
from mobilizon_reshare.publishers.platforms.telegram import ( from mobilizon_reshare.publishers.platforms.telegram import (
TelegramFormatter, TelegramFormatter,
TelegramPublisher, TelegramPublisher,
@ -11,13 +15,22 @@ from mobilizon_reshare.publishers.platforms.telegram import (
def test_message_length_success(event): def test_message_length_success(event):
message = "a" * 500 message = "a" * 500
event.description = message event.description = message
assert TelegramFormatter().is_message_valid(event) assert (
TelegramFormatter().validate_message(
TelegramFormatter().get_message_from_event(event)
)
is None
)
def test_message_length_failure(event): def test_message_length_failure(event):
message = "a" * 10000 message = "a" * 10000
event.description = message event.description = message
assert not TelegramFormatter().is_message_valid(event)
with pytest.raises(InvalidMessage):
TelegramFormatter().validate_message(
TelegramFormatter().get_message_from_event(event)
)
@pytest.mark.parametrize( @pytest.mark.parametrize(
@ -68,7 +81,7 @@ def test_validate_response_invalid_request():
response = requests.Response() response = requests.Response()
response.status_code = 400 response.status_code = 400
response._content = b"""{"error":true}""" response._content = b"""{"error":true}"""
with pytest.raises(HTTPResponseError) as e: with pytest.raises(InvalidResponse) as e:
TelegramPublisher()._validate_response(response) TelegramPublisher()._validate_response(response)

View File

@ -13,6 +13,7 @@ from mobilizon_reshare.publishers.exceptions import (
InvalidEvent, InvalidEvent,
InvalidResponse, InvalidResponse,
ZulipError, ZulipError,
InvalidMessage,
) )
from mobilizon_reshare.publishers.platforms.zulip import ZulipFormatter, ZulipPublisher from mobilizon_reshare.publishers.platforms.zulip import ZulipFormatter, ZulipPublisher
from mobilizon_reshare.storage.query import ( from mobilizon_reshare.storage.query import (
@ -139,7 +140,7 @@ async def test_zulip_publishr_failure_invalid_credentials(
) )
).run() ).run()
assert report.reports[0].status == PublicationStatus.FAILED assert report.reports[0].status == PublicationStatus.FAILED
assert report.reports[0].reason == "Invalid credentials" assert report.reports[0].reason == "403 Error - Your credentials are not valid!"
@pytest.mark.asyncio @pytest.mark.asyncio
@ -170,13 +171,21 @@ def test_event_validation(event):
def test_message_length_success(event): def test_message_length_success(event):
message = "a" * 500 message = "a" * 500
event.description = message event.description = message
assert ZulipFormatter().is_message_valid(event) assert (
ZulipFormatter().validate_message(
ZulipFormatter().get_message_from_event(event)
)
is None
)
def test_message_length_failure(event): def test_message_length_failure(event):
message = "a" * 10000 message = "a" * 10000
event.description = message event.description = message
assert not ZulipFormatter().is_message_valid(event) with pytest.raises(InvalidMessage):
ZulipFormatter().validate_message(
ZulipFormatter().get_message_from_event(event)
)
def test_validate_response(): def test_validate_response():

View File

@ -7,6 +7,7 @@ import pytest
from mobilizon_reshare.event.event import MobilizonEvent, EventPublicationStatus from mobilizon_reshare.event.event import MobilizonEvent, EventPublicationStatus
from mobilizon_reshare.models.publication import PublicationStatus, Publication from mobilizon_reshare.models.publication import PublicationStatus, Publication
from mobilizon_reshare.models.publisher import Publisher from mobilizon_reshare.models.publisher import Publisher
from mobilizon_reshare.publishers.abstract import EventPublication
from mobilizon_reshare.publishers.coordinator import ( from mobilizon_reshare.publishers.coordinator import (
PublisherCoordinatorReport, PublisherCoordinatorReport,
EventPublicationReport, EventPublicationReport,
@ -73,12 +74,16 @@ async def test_update_publishers(
EventPublicationReport( EventPublicationReport(
status=PublicationStatus.FAILED, status=PublicationStatus.FAILED,
reason="Invalid credentials", reason="Invalid credentials",
publication_id=UUID(int=3), publication=EventPublication(
id=UUID(int=3), formatter=None, event=None, publisher=None
),
), ),
EventPublicationReport( EventPublicationReport(
status=PublicationStatus.COMPLETED, status=PublicationStatus.COMPLETED,
reason="", reason="",
publication_id=UUID(int=4), publication=EventPublication(
id=UUID(int=4), formatter=None, event=None, publisher=None
),
), ),
], ],
), ),