goatnotify-core 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,86 @@
1
+ from goatnotify.core import events
2
+ from goatnotify.core._json import JsonValue
3
+ from goatnotify.core.channel import Channel
4
+ from goatnotify.core.channels.database import (
5
+ DatabaseChannel,
6
+ DatabaseMessage,
7
+ NotificationStore,
8
+ StoredNotification,
9
+ )
10
+ from goatnotify.core.channels.email import EmailChannel
11
+ from goatnotify.core.channels.webhook import WebhookChannel, WebhookMessage
12
+ from goatnotify.core.errors import (
13
+ ConfigurationError,
14
+ DeliveryError,
15
+ DeliveryFailed,
16
+ InvalidMessageError,
17
+ NotificationError,
18
+ PermanentError,
19
+ RetryableError,
20
+ SkipDelivery,
21
+ )
22
+ from goatnotify.core.events import Event, Hook
23
+ from goatnotify.core.http import HttpRequest, HttpResponse, HttpTransport, UrllibTransport
24
+ from goatnotify.core.mail.mailable import Mailable
25
+ from goatnotify.core.mail.mailer import Mailer
26
+ from goatnotify.core.mail.message import Address, Attachment, EmailMessage
27
+ from goatnotify.core.mail.mime import to_mime
28
+ from goatnotify.core.mail.smtp import SmtpTransport
29
+ from goatnotify.core.mail.transport import EmailTransport
30
+ from goatnotify.core.notification import Message, Notifiable, Notification
31
+ from goatnotify.core.notifier import Notifier
32
+ from goatnotify.core.queue import AsyncioQueue, DrainableQueue, NotificationJob, Queue
33
+ from goatnotify.core.results import DeliveryResult, DeliveryStatus, NotificationResult
34
+ from goatnotify.core.retry import NO_RETRY, Backoff, ExponentialBackoff, RetryPolicy
35
+ from goatnotify.core.templates import TemplateRenderer
36
+
37
+ __all__ = [
38
+ "NO_RETRY",
39
+ "Address",
40
+ "AsyncioQueue",
41
+ "Attachment",
42
+ "Backoff",
43
+ "Channel",
44
+ "ConfigurationError",
45
+ "DatabaseChannel",
46
+ "DatabaseMessage",
47
+ "DeliveryError",
48
+ "DeliveryFailed",
49
+ "DeliveryResult",
50
+ "DeliveryStatus",
51
+ "DrainableQueue",
52
+ "EmailChannel",
53
+ "EmailMessage",
54
+ "EmailTransport",
55
+ "Event",
56
+ "ExponentialBackoff",
57
+ "Hook",
58
+ "HttpRequest",
59
+ "HttpResponse",
60
+ "HttpTransport",
61
+ "InvalidMessageError",
62
+ "JsonValue",
63
+ "Mailable",
64
+ "Mailer",
65
+ "Message",
66
+ "Notifiable",
67
+ "Notification",
68
+ "NotificationError",
69
+ "NotificationJob",
70
+ "NotificationResult",
71
+ "NotificationStore",
72
+ "Notifier",
73
+ "PermanentError",
74
+ "Queue",
75
+ "RetryPolicy",
76
+ "RetryableError",
77
+ "SkipDelivery",
78
+ "SmtpTransport",
79
+ "StoredNotification",
80
+ "TemplateRenderer",
81
+ "UrllibTransport",
82
+ "WebhookChannel",
83
+ "WebhookMessage",
84
+ "events",
85
+ "to_mime",
86
+ ]
@@ -0,0 +1 @@
1
+ type JsonValue = str | int | float | bool | list[JsonValue] | dict[str, JsonValue] | None
@@ -0,0 +1,13 @@
1
+ from abc import ABC, abstractmethod
2
+
3
+ from goatnotify.core.notification import Message, Notifiable
4
+ from goatnotify.core.retry import RetryPolicy
5
+
6
+
7
+ class Channel[M: Message](ABC):
8
+ name: str
9
+ message_type: type[M]
10
+ retry: RetryPolicy | None = None
11
+
12
+ @abstractmethod
13
+ async def send(self, notifiable: Notifiable, message: M) -> str | None: ...
File without changes
@@ -0,0 +1,55 @@
1
+ from collections.abc import Callable, Mapping
2
+ from dataclasses import dataclass
3
+ from datetime import UTC, datetime
4
+ from typing import Protocol
5
+ from uuid import UUID, uuid4
6
+
7
+ from goatnotify.core._json import JsonValue
8
+ from goatnotify.core.channel import Channel
9
+ from goatnotify.core.notification import Message, Notifiable
10
+ from goatnotify.core.retry import RetryPolicy
11
+
12
+
13
+ @dataclass(frozen=True, slots=True)
14
+ class DatabaseMessage(Message):
15
+ kind: str
16
+ data: Mapping[str, JsonValue]
17
+
18
+
19
+ @dataclass(frozen=True, slots=True)
20
+ class StoredNotification:
21
+ id: UUID
22
+ kind: str
23
+ data: Mapping[str, JsonValue]
24
+ created_at: datetime
25
+
26
+
27
+ class NotificationStore(Protocol):
28
+ async def store(self, notifiable: Notifiable, record: StoredNotification) -> None: ...
29
+
30
+
31
+ def utc_now() -> datetime:
32
+ return datetime.now(UTC)
33
+
34
+
35
+ class DatabaseChannel(Channel[DatabaseMessage]):
36
+ name = "database"
37
+ message_type = DatabaseMessage
38
+
39
+ def __init__(
40
+ self,
41
+ store: NotificationStore,
42
+ *,
43
+ retry: RetryPolicy | None = None,
44
+ clock: Callable[[], datetime] = utc_now,
45
+ ) -> None:
46
+ self._store = store
47
+ self._clock = clock
48
+ self.retry = retry
49
+
50
+ async def send(self, notifiable: Notifiable, message: DatabaseMessage) -> str | None:
51
+ record = StoredNotification(
52
+ id=uuid4(), kind=message.kind, data=message.data, created_at=self._clock()
53
+ )
54
+ await self._store.store(notifiable, record)
55
+ return str(record.id)
@@ -0,0 +1,23 @@
1
+ from goatnotify.core.channel import Channel
2
+ from goatnotify.core.errors import SkipDelivery
3
+ from goatnotify.core.mail.mailable import Mailable
4
+ from goatnotify.core.mail.mailer import Mailer
5
+ from goatnotify.core.notification import Notifiable
6
+ from goatnotify.core.retry import RetryPolicy
7
+
8
+
9
+ class EmailChannel(Channel[Mailable]):
10
+ name = "email"
11
+ message_type = Mailable
12
+
13
+ def __init__(self, mailer: Mailer, *, retry: RetryPolicy | None = None) -> None:
14
+ self._mailer = mailer
15
+ self.retry = retry
16
+
17
+ async def send(self, notifiable: Notifiable, message: Mailable) -> str | None:
18
+ if message.to:
19
+ return await self._mailer.send(message)
20
+ route = notifiable.route_notification(self.name)
21
+ if route is None:
22
+ raise SkipDelivery(f"{type(notifiable).__qualname__} has no email route")
23
+ return await self._mailer.send(message, to=[route])
@@ -0,0 +1,104 @@
1
+ import hashlib
2
+ import hmac
3
+ import json
4
+ import time
5
+ from collections.abc import Callable, Mapping
6
+ from dataclasses import dataclass, field
7
+ from typing import Final, Literal
8
+ from urllib.parse import urlsplit
9
+
10
+ from goatnotify.core._json import JsonValue
11
+ from goatnotify.core.channel import Channel
12
+ from goatnotify.core.errors import DeliveryError, PermanentError, RetryableError, SkipDelivery
13
+ from goatnotify.core.http import (
14
+ HttpRequest,
15
+ HttpResponse,
16
+ HttpTransport,
17
+ UrllibTransport,
18
+ require_http_url,
19
+ )
20
+ from goatnotify.core.notification import Message, Notifiable
21
+ from goatnotify.core.retry import RetryPolicy
22
+
23
+ SIGNATURE_HEADER: Final = "X-GoatNotify-Signature"
24
+ TIMESTAMP_HEADER: Final = "X-GoatNotify-Timestamp"
25
+ MAX_RETRY_AFTER: Final = 300.0
26
+ RETRYABLE_STATUSES: Final = frozenset({408, 429})
27
+ ERROR_BODY_LIMIT: Final = 200
28
+
29
+
30
+ @dataclass(frozen=True, slots=True)
31
+ class WebhookMessage(Message):
32
+ payload: JsonValue
33
+ url: str | None = None
34
+ headers: Mapping[str, str] = field(default_factory=dict[str, str])
35
+ method: Literal["POST", "PUT"] = "POST"
36
+
37
+
38
+ class WebhookChannel(Channel[WebhookMessage]):
39
+ name = "webhook"
40
+ message_type = WebhookMessage
41
+
42
+ def __init__(
43
+ self,
44
+ transport: HttpTransport | None = None,
45
+ *,
46
+ secret: str | None = None,
47
+ timeout: float = 10.0,
48
+ retry: RetryPolicy | None = None,
49
+ clock: Callable[[], float] = time.time,
50
+ ) -> None:
51
+ self._transport = transport or UrllibTransport()
52
+ self._secret = secret
53
+ self._timeout = timeout
54
+ self._clock = clock
55
+ self.retry = retry
56
+
57
+ async def send(self, notifiable: Notifiable, message: WebhookMessage) -> str | None:
58
+ url = message.url or notifiable.route_notification(self.name)
59
+ if url is None:
60
+ raise SkipDelivery(f"{type(notifiable).__qualname__} has no webhook route")
61
+ require_http_url(url)
62
+ body = json.dumps(message.payload, separators=(",", ":"), ensure_ascii=False).encode()
63
+ headers = {"Content-Type": "application/json", **message.headers, **self._sign(body)}
64
+ host = urlsplit(url).netloc
65
+ try:
66
+ response = await self._transport.send(
67
+ HttpRequest(message.method, url, headers, body, self._timeout)
68
+ )
69
+ except DeliveryError:
70
+ raise
71
+ # "from None": transport errors often quote the full URL, which may embed a token.
72
+ except OSError as error:
73
+ detail = f"Webhook request to {host} failed: {type(error).__name__}"
74
+ raise RetryableError(detail) from None
75
+ except Exception as error:
76
+ detail = f"Webhook request to {host} failed: {type(error).__name__}"
77
+ raise PermanentError(detail) from None
78
+ _raise_for_status(response, host)
79
+ return None
80
+
81
+ def _sign(self, body: bytes) -> dict[str, str]:
82
+ if self._secret is None:
83
+ return {}
84
+ timestamp = str(int(self._clock()))
85
+ digest = hmac.new(
86
+ self._secret.encode(), f"{timestamp}.".encode() + body, hashlib.sha256
87
+ ).hexdigest()
88
+ return {TIMESTAMP_HEADER: timestamp, SIGNATURE_HEADER: f"sha256={digest}"}
89
+
90
+
91
+ def _raise_for_status(response: HttpResponse, host: str) -> None:
92
+ if 200 <= response.status < 300:
93
+ return
94
+ detail = f"Webhook {host} answered {response.status}: {response.body[:ERROR_BODY_LIMIT]!r}"
95
+ if response.status in RETRYABLE_STATUSES or response.status >= 500:
96
+ raise RetryableError(detail, retry_after=_retry_after(response))
97
+ raise PermanentError(detail)
98
+
99
+
100
+ def _retry_after(response: HttpResponse) -> float | None:
101
+ value = (response.header("Retry-After") or "").strip()
102
+ if not (value.isascii() and value.isdecimal()):
103
+ return None
104
+ return min(float(value), MAX_RETRY_AFTER)
@@ -0,0 +1,47 @@
1
+ from typing import TYPE_CHECKING, Self
2
+
3
+ if TYPE_CHECKING:
4
+ from goatnotify.core.results import NotificationResult
5
+
6
+
7
+ class NotificationError(Exception):
8
+ pass
9
+
10
+
11
+ class ConfigurationError(NotificationError):
12
+ pass
13
+
14
+
15
+ class DeliveryError(NotificationError):
16
+ pass
17
+
18
+
19
+ class RetryableError(DeliveryError):
20
+ def __init__(self, message: str, *, retry_after: float | None = None) -> None:
21
+ super().__init__(message)
22
+ self.retry_after = retry_after
23
+
24
+
25
+ class PermanentError(DeliveryError):
26
+ pass
27
+
28
+
29
+ class InvalidMessageError(PermanentError):
30
+ pass
31
+
32
+
33
+ class SkipDelivery(DeliveryError): # noqa: N818 (a control-flow signal, not an error)
34
+ def __init__(self, reason: str) -> None:
35
+ super().__init__(reason)
36
+ self.reason = reason
37
+
38
+
39
+ class DeliveryFailed(ExceptionGroup[Exception]):
40
+ result: NotificationResult
41
+
42
+ @classmethod
43
+ def from_result(cls, result: NotificationResult) -> Self:
44
+ errors = [delivery.error for delivery in result.deliveries if delivery.error is not None]
45
+ group = cls(f"{len(errors)} of {len(result.deliveries)} deliveries failed", errors)
46
+ group.result = result
47
+ return group
@@ -0,0 +1,51 @@
1
+ from collections.abc import Awaitable, Callable
2
+ from dataclasses import dataclass
3
+ from typing import Any, ClassVar, Literal
4
+ from uuid import UUID
5
+
6
+ from goatnotify.core.errors import RetryableError
7
+ from goatnotify.core.notification import Message, Notifiable, Notification
8
+
9
+
10
+ @dataclass(frozen=True, slots=True, kw_only=True)
11
+ class _DeliveryEvent:
12
+ notification_id: UUID
13
+ notification: Notification[Any]
14
+ notifiable: Notifiable
15
+ channel: str
16
+ message: Message
17
+ attempt: int
18
+
19
+
20
+ @dataclass(frozen=True, slots=True, kw_only=True)
21
+ class Sending(_DeliveryEvent):
22
+ name: ClassVar[Literal["notification.sending"]] = "notification.sending"
23
+
24
+
25
+ @dataclass(frozen=True, slots=True, kw_only=True)
26
+ class Retrying(_DeliveryEvent):
27
+ name: ClassVar[Literal["notification.retrying"]] = "notification.retrying"
28
+ delay: float
29
+ error: RetryableError
30
+
31
+
32
+ @dataclass(frozen=True, slots=True, kw_only=True)
33
+ class Sent(_DeliveryEvent):
34
+ name: ClassVar[Literal["notification.sent"]] = "notification.sent"
35
+ provider_id: str | None
36
+
37
+
38
+ @dataclass(frozen=True, slots=True, kw_only=True)
39
+ class Failed(_DeliveryEvent):
40
+ name: ClassVar[Literal["notification.failed"]] = "notification.failed"
41
+ error: Exception
42
+
43
+
44
+ @dataclass(frozen=True, slots=True, kw_only=True)
45
+ class Skipped(_DeliveryEvent):
46
+ name: ClassVar[Literal["notification.skipped"]] = "notification.skipped"
47
+ reason: str
48
+
49
+
50
+ type Event = Sending | Retrying | Sent | Failed | Skipped
51
+ type Hook = Callable[[Event], Awaitable[None] | None]
@@ -0,0 +1,102 @@
1
+ import asyncio
2
+ import urllib.error
3
+ import urllib.request
4
+ from collections.abc import Mapping
5
+ from dataclasses import dataclass
6
+ from email.message import Message
7
+ from importlib.metadata import PackageNotFoundError, version
8
+ from typing import IO, Protocol, override
9
+ from urllib.parse import urlsplit
10
+
11
+ from goatnotify.core.errors import InvalidMessageError
12
+
13
+
14
+ def _distribution_version() -> str:
15
+ try:
16
+ return version("goatnotify-core")
17
+ except PackageNotFoundError:
18
+ return "unknown"
19
+
20
+
21
+ USER_AGENT = f"goatnotify/{_distribution_version()}"
22
+ ALLOWED_SCHEMES = frozenset({"http", "https"})
23
+
24
+
25
+ def require_http_url(url: str) -> None:
26
+ parts = urlsplit(url)
27
+ if parts.scheme not in ALLOWED_SCHEMES or not parts.netloc:
28
+ raise InvalidMessageError("Only absolute http and https URLs are allowed")
29
+ if not url.isascii() or not url.isprintable() or " " in url:
30
+ raise InvalidMessageError("URLs must be percent-encoded ASCII without spaces")
31
+
32
+
33
+ @dataclass(frozen=True, slots=True)
34
+ class HttpRequest:
35
+ method: str
36
+ url: str
37
+ headers: Mapping[str, str]
38
+ body: bytes
39
+ timeout: float
40
+
41
+
42
+ @dataclass(frozen=True, slots=True)
43
+ class HttpResponse:
44
+ status: int
45
+ headers: Mapping[str, str]
46
+ body: bytes
47
+
48
+ def header(self, name: str) -> str | None:
49
+ wanted = name.lower()
50
+ return next((v for k, v in self.headers.items() if k.lower() == wanted), None)
51
+
52
+
53
+ class HttpTransport(Protocol):
54
+ async def send(self, request: HttpRequest) -> HttpResponse: ...
55
+
56
+
57
+ def _with_user_agent(headers: Mapping[str, str]) -> dict[str, str]:
58
+ # urllib's default "Python-urllib" agent is rejected by some bot-protected endpoints.
59
+ if any(name.lower() == "user-agent" for name in headers):
60
+ return dict(headers)
61
+ return {**headers, "User-Agent": USER_AGENT}
62
+
63
+
64
+ class _NoRedirect(urllib.request.HTTPRedirectHandler):
65
+ # urllib would replay a POST as a bodiless GET and forward signature headers to any host.
66
+ @override
67
+ def redirect_request(
68
+ self,
69
+ req: urllib.request.Request,
70
+ fp: IO[bytes],
71
+ code: int,
72
+ msg: str,
73
+ headers: Message,
74
+ newurl: str,
75
+ ) -> None:
76
+ return None
77
+
78
+
79
+ class UrllibTransport:
80
+ def __init__(self) -> None:
81
+ self._opener = urllib.request.build_opener(_NoRedirect)
82
+
83
+ async def send(self, request: HttpRequest) -> HttpResponse:
84
+ require_http_url(request.url)
85
+ return await asyncio.to_thread(self._send, request)
86
+
87
+ def _send(self, request: HttpRequest) -> HttpResponse:
88
+ prepared = urllib.request.Request( # noqa: S310 (scheme checked by require_http_url)
89
+ request.url,
90
+ data=request.body,
91
+ headers=_with_user_agent(request.headers),
92
+ method=request.method,
93
+ )
94
+ try:
95
+ # Safe: send() has already restricted the URL to http and https.
96
+ with self._opener.open(prepared, timeout=request.timeout) as response: # nosemgrep
97
+ return HttpResponse(
98
+ response.status, dict(response.headers.items()), response.read()
99
+ )
100
+ except urllib.error.HTTPError as error:
101
+ with error:
102
+ return HttpResponse(error.code, dict(error.headers.items()), error.read())
File without changes
@@ -0,0 +1,36 @@
1
+ from collections.abc import Mapping, Sequence
2
+ from types import MappingProxyType
3
+
4
+ from goatnotify.core.mail.message import AddressLike, Attachment
5
+ from goatnotify.core.notification import Message
6
+
7
+ _EMPTY: MappingProxyType[str, str] = MappingProxyType({})
8
+
9
+
10
+ class Mailable(Message):
11
+ subject: str = ""
12
+ sender: AddressLike | None = None
13
+ to: Sequence[AddressLike] = ()
14
+ cc: Sequence[AddressLike] = ()
15
+ bcc: Sequence[AddressLike] = ()
16
+ reply_to: Sequence[AddressLike] = ()
17
+ headers: Mapping[str, str] = _EMPTY
18
+ metadata: Mapping[str, str] = _EMPTY
19
+
20
+ def attachments(self) -> Sequence[Attachment]:
21
+ return ()
22
+
23
+ def html(self) -> str | None:
24
+ return None
25
+
26
+ def text(self) -> str | None:
27
+ return None
28
+
29
+ def template(self) -> str | None:
30
+ return None
31
+
32
+ def text_template(self) -> str | None:
33
+ return None
34
+
35
+ def context(self) -> Mapping[str, object]:
36
+ return _EMPTY
@@ -0,0 +1,60 @@
1
+ from collections.abc import Iterable, Mapping, Sequence
2
+
3
+ from goatnotify.core.errors import ConfigurationError, InvalidMessageError
4
+ from goatnotify.core.mail.mailable import Mailable
5
+ from goatnotify.core.mail.message import Address, AddressLike, EmailMessage
6
+ from goatnotify.core.mail.transport import EmailTransport
7
+ from goatnotify.core.templates import TemplateRenderer
8
+
9
+
10
+ class Mailer:
11
+ def __init__(
12
+ self,
13
+ transport: EmailTransport,
14
+ *,
15
+ sender: AddressLike,
16
+ renderer: TemplateRenderer | None = None,
17
+ ) -> None:
18
+ self._transport = transport
19
+ self._sender = Address.parse(sender)
20
+ self._renderer = renderer
21
+
22
+ async def render(self, mailable: Mailable, *, to: Sequence[AddressLike] = ()) -> EmailMessage:
23
+ if not mailable.subject:
24
+ raise InvalidMessageError(f"{type(mailable).__qualname__} has no subject")
25
+ context = mailable.context()
26
+ html = mailable.html()
27
+ if html is None:
28
+ html = await self._render(mailable.template(), context)
29
+ text = mailable.text()
30
+ if text is None:
31
+ text = await self._render(mailable.text_template(), context)
32
+ return EmailMessage(
33
+ subject=mailable.subject,
34
+ sender=self._sender if mailable.sender is None else Address.parse(mailable.sender),
35
+ to=_addresses(to or mailable.to),
36
+ cc=_addresses(mailable.cc),
37
+ bcc=_addresses(mailable.bcc),
38
+ reply_to=_addresses(mailable.reply_to),
39
+ html=html,
40
+ text=text,
41
+ attachments=tuple(mailable.attachments()),
42
+ headers=dict(mailable.headers),
43
+ metadata=dict(mailable.metadata),
44
+ )
45
+
46
+ async def send(self, mailable: Mailable, *, to: Sequence[AddressLike] = ()) -> str | None:
47
+ return await self._transport.send(await self.render(mailable, to=to))
48
+
49
+ async def _render(self, template: str | None, context: Mapping[str, object]) -> str | None:
50
+ if template is None:
51
+ return None
52
+ if self._renderer is None:
53
+ raise ConfigurationError(
54
+ f"Template {template!r} needs a TemplateRenderer: pass renderer= to Mailer"
55
+ )
56
+ return await self._renderer.render(template, context)
57
+
58
+
59
+ def _addresses(values: Iterable[AddressLike]) -> tuple[Address, ...]:
60
+ return tuple(Address.parse(value) for value in values)