Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 22 additions & 12 deletions tests/test_publishers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,18 @@
import pytest

from weather_briefing.api_client import LoggedAsyncClient
from weather_briefing.models import RenderedMessage
from weather_briefing.publishers import (
from weather_briefing.data import resources as data_resources
from weather_briefing.data.resources import ReferenceDataError
from weather_briefing.delivery import (
DeliveryError,
DeliveryProvider,
PlainTextRenderer,
StdoutPublisher,
TelegramPublisher,
_split_message,
)
from weather_briefing.reference_data import ReferenceDataError
from weather_briefing.render import PlainTextRenderer
from weather_briefing.delivery.telegram import split_message
from weather_briefing.delivery.telegram_reference import telegram_error_classification
from weather_briefing.models import RenderedMessage


class NoopPublisher:
Expand Down Expand Up @@ -70,13 +72,11 @@ def test_delivery_provider_applies_platform_limit_without_leaking_it_into_config


async def test_telegram_publisher_validates_error_metadata_on_construction(monkeypatch) -> None:
def fail_validation() -> None:
raise ReferenceDataError("invalid Telegram metadata")

monkeypatch.setattr("weather_briefing.publishers.telegram_error_classification", fail_validation)
monkeypatch.setattr(data_resources, "load_reference_data", lambda filename: {})
telegram_error_classification.cache_clear()

async with httpx.AsyncClient() as client:
with pytest.raises(ReferenceDataError, match="invalid Telegram metadata"):
with pytest.raises(ReferenceDataError, match="supported fields"):
TelegramPublisher(client, "runtime-token", "runtime-chat")


Expand All @@ -93,7 +93,17 @@ def test_delivery_error_rejects_non_boolean_channel_availability(value) -> None:


def test_split_message_prefers_line_boundary() -> None:
assert _split_message("first line\nsecond line", 12) == ("first line", "\nsecond line")
assert split_message("first line\nsecond line", 12) == ("first line", "\nsecond line")


def test_split_message_preserves_markup_without_visible_text() -> None:
assert split_message("<b></b>", 3) == ("<b></b>",)


@pytest.mark.parametrize(("body", "limit"), (("", 0), ("body", 0), ("body", -1)))
def test_split_message_rejects_non_positive_limit(body: str, limit: int) -> None:
with pytest.raises(ValueError, match="must be positive"):
split_message(body, limit)


@pytest.mark.parametrize(
Expand Down Expand Up @@ -122,7 +132,7 @@ def test_split_message_balances_html_tags(
limit: int,
expected: tuple[str, ...],
) -> None:
assert _split_message(body, limit) == expected
assert split_message(body, limit) == expected


async def test_telegram_publisher_uses_runtime_values(caplog) -> None:
Expand Down
5 changes: 3 additions & 2 deletions tests/test_reference_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

from weather_briefing import __version__
from weather_briefing.air_quality import health_guidance
from weather_briefing.data import resources as data_resources
from weather_briefing.data.resources import (
ReferenceDataError,
load_reference_data,
Expand All @@ -14,10 +15,10 @@
reference_value,
)
from weather_briefing.data.service_endpoints import NOMINATIM_USER_AGENT
from weather_briefing.delivery.telegram_reference import telegram_error_classification
from weather_briefing.localization import localization_table
from weather_briefing.reference_data import (
open_meteo_weather_code_descriptions,
telegram_error_classification,
)


Expand Down Expand Up @@ -214,7 +215,7 @@ def test_reference_string_rejects_invalid_value(monkeypatch, value) -> None:
),
)
def test_telegram_error_classification_rejects_invalid_data(monkeypatch, value, message) -> None:
monkeypatch.setattr("weather_briefing.reference_data.load_reference_data", lambda filename: value)
monkeypatch.setattr(data_resources, "load_reference_data", lambda filename: value)
telegram_error_classification.cache_clear()

with pytest.raises(ReferenceDataError, match=message):
Expand Down
2 changes: 1 addition & 1 deletion tests/test_render.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import pendulum
import pytest

from weather_briefing.delivery import PlainTextRenderer, TelegramHTMLRenderer
from weather_briefing.models import (
Advice,
AdviceTopic,
Expand All @@ -10,7 +11,6 @@
SourceDocument,
Warning,
)
from weather_briefing.render import PlainTextRenderer, TelegramHTMLRenderer


def test_render_briefing_uses_safe_telegram_html() -> None:
Expand Down
16 changes: 16 additions & 0 deletions weather_briefing/delivery/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""Delivery contracts, renderers, and platform adapters."""

from .base import DeliveryError, DeliveryProvider, RenderedTextDiagnostics
from .renderers import PlainTextRenderer, TelegramHTMLRenderer
from .stdout import StdoutPublisher
from .telegram import TelegramPublisher

__all__ = [
"DeliveryError",
"DeliveryProvider",
"PlainTextRenderer",
"RenderedTextDiagnostics",
"StdoutPublisher",
"TelegramHTMLRenderer",
"TelegramPublisher",
]
121 changes: 121 additions & 0 deletions weather_briefing/delivery/base.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
"""Platform-neutral message delivery composition."""

from __future__ import annotations

import logging
import re
from dataclasses import dataclass
from typing import Protocol

from ..models import Article, BriefingResult, RenderedMessage, SourceDocument
from .renderers import MessageRenderer

_LOGGER = logging.getLogger("weather_briefing.publishers")
_SAFE_DELIVERY_REASON = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*")


class Publisher(Protocol):
"""Transport a rendered message to its destination."""

async def publish(
self,
message: RenderedMessage,
*,
single_message: bool = False,
silent: bool = False,
) -> None:
"""Publish one rendered message with delivery hints."""
...


class RenderedTextDiagnostics(Protocol):
"""Expose the runtime switch for sensitive rendered-text logging."""

def rendered_text_logging_enabled(self) -> bool:
"""Return whether sensitive rendered-text logging is enabled."""
...


@dataclass(frozen=True, slots=True)
class DeliveryProvider:
"""Bind a platform renderer to its message transport."""

renderer: MessageRenderer
publisher: Publisher
single_message_limit: int | None = None
diagnostics: RenderedTextDiagnostics | None = None

def briefing_limit(self, configured_limit: int) -> int:
"""Clamp a configured briefing limit to the platform limit."""
if self.single_message_limit is None:
return configured_limit
return min(configured_limit, self.single_message_limit)

def render_briefing(
self,
result: BriefingResult,
reference_articles: tuple[Article, ...],
context: tuple[SourceDocument, ...],
) -> RenderedMessage:
"""Render a briefing with the configured platform renderer."""
return self.renderer.render_briefing(result, reference_articles, context)

async def publish_rendered(
self,
message: RenderedMessage,
*,
single_message: bool = False,
silent: bool = False,
) -> None:
"""Publish an already rendered message with delivery hints."""
log_rendered_text(self.diagnostics, "briefing", message.body)
await self.publisher.publish(message, single_message=single_message, silent=silent)

async def publish_verbatim(self, article: Article, *, silent: bool = False) -> None:
"""Render and publish one cleaned article without summarization."""
message = self.renderer.render_verbatim(article)
_LOGGER.debug(
"Rendered verbatim message: visible_characters=%d payload_characters=%d",
message.visible_length,
len(message.body),
)
log_rendered_text(self.diagnostics, "verbatim", message.body)
await self.publisher.publish(message, silent=silent)

async def publish_alert(self, title: str, body: str) -> None:
"""Render and publish an operational alert."""
message = self.renderer.render_alert(title, body)
log_rendered_text(self.diagnostics, "alert", message.body)
await self.publisher.publish(message)


class DeliveryError(RuntimeError):
"""Raised without exposing private delivery endpoint details."""

def __init__(self, message: str, *, reason: str, channel_unavailable: bool = False) -> None:
"""Describe a delivery failure using a safe structured reason."""
if not isinstance(reason, str) or _SAFE_DELIVERY_REASON.fullmatch(reason) is None:
raise ValueError("Delivery error reason must be a lowercase kebab-case label")
if not isinstance(channel_unavailable, bool):
raise TypeError("channel_unavailable must be a bool")
super().__init__(message)
self.reason = reason
self.channel_unavailable = channel_unavailable


def log_rendered_text(diagnostics: RenderedTextDiagnostics | None, stage: str, body: str) -> None:
"""Log application-owned rendered text when its temporary switch is enabled."""
if rendered_text_logging_enabled(diagnostics):
_LOGGER.debug("Sensitive rendered text diagnostic: stage=%s body=%r", stage, body)


def rendered_text_logging_enabled(diagnostics: RenderedTextDiagnostics | None) -> bool:
"""Read the diagnostic switch without affecting delivery on failure."""
if diagnostics is None:
return False
try:
enabled = diagnostics.rendered_text_logging_enabled()
except Exception:
_LOGGER.warning("Rendered text diagnostic state check failed", exc_info=True)
return False
return enabled and _LOGGER.isEnabledFor(logging.DEBUG)
Loading