Enabled all Ruff lint rules and resolved findings.
CI / Formatting (push) Successful in 27s
CI / Linting (push) Successful in 9s
CI / Tests (push) Successful in 34s
CI / Type Checking (push) Successful in 7s
CI / Spelling (push) Successful in 7s

This commit is contained in:
2026-05-18 11:10:46 -04:00
parent 48a4fb9bbf
commit 795184cd17
11 changed files with 211 additions and 123 deletions
+8 -4
View File
@@ -33,6 +33,10 @@ if TYPE_CHECKING:
from collections.abc import AsyncIterator
from pathlib import Path
from maubot import Plugin
from mautrix.util.async_db import UpgradeTable
from mautrix.util.config import BaseProxyConfig
from owncastsentry.metrics import MetricsService
from owncastsentry.types import StreamConfig, StreamState
@@ -72,19 +76,19 @@ def subscription_repo(database: Database) -> SubscriptionRepository:
@pytest.fixture
def maubot_plugin_class():
def maubot_plugin_class() -> type[Plugin]:
"""Use OwncastSentry as the plugin class for maubot integration tests."""
return OwncastSentry
@pytest.fixture
def maubot_plugin_config_class():
def maubot_plugin_config_class() -> type[BaseProxyConfig]:
"""Use our Config class for maubot integration tests."""
return Config
@pytest.fixture
def maubot_upgrade_table():
def maubot_upgrade_table() -> UpgradeTable:
"""Provide the database upgrade table for maubot integration tests."""
return get_upgrade_table()
@@ -220,7 +224,7 @@ class _StubOwncastClient:
self.queried_domains.append(domain)
return self.stream_state
async def get_stream_config(self, domain: str) -> StreamConfig | None:
async def get_stream_config(self, _domain: str) -> StreamConfig | None:
"""Return the configured stream config."""
self.config_call_count += 1
return self.stream_config
+46 -29
View File
@@ -16,12 +16,16 @@
import json
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING
import pytest
import time_machine
from aioresponses import aioresponses
from owncastsentry.commands import (
_SECONDS_PER_DAY,
_SECONDS_PER_HOUR,
_SECONDS_PER_MINUTE,
_escape_markdown,
_format_duration,
_sanitize_for_markdown,
@@ -30,6 +34,11 @@ from owncastsentry.owncast_client import _OWNCAST_STATUS_PATH
from owncastsentry.types import UNKNOWN_STATUS_THRESHOLD, StreamState
from tests.conftest import VALID_STATUS_RESPONSE
if TYPE_CHECKING:
from maubot.testing.bot import TestBot
from owncastsentry import OwncastSentry
class TestEscapeMarkdown:
"""Markdown special character escaping."""
@@ -78,12 +87,12 @@ class TestFormatDuration:
[
pytest.param(1, "1 second", id="singular-second"),
pytest.param(30, "30 seconds", id="plural-seconds"),
pytest.param(60, "1 minute", id="singular-minute"),
pytest.param(120, "2 minutes", id="plural-minutes"),
pytest.param(3600, "1 hour", id="singular-hour"),
pytest.param(7200, "2 hours", id="plural-hours"),
pytest.param(86400, "1 day", id="singular-day"),
pytest.param(172800, "2 days", id="plural-days"),
pytest.param(_SECONDS_PER_MINUTE, "1 minute", id="singular-minute"),
pytest.param(2 * _SECONDS_PER_MINUTE, "2 minutes", id="plural-minutes"),
pytest.param(_SECONDS_PER_HOUR, "1 hour", id="singular-hour"),
pytest.param(2 * _SECONDS_PER_HOUR, "2 hours", id="plural-hours"),
pytest.param(_SECONDS_PER_DAY, "1 day", id="singular-day"),
pytest.param(2 * _SECONDS_PER_DAY, "2 days", id="plural-days"),
],
)
def test_formats_duration(self, seconds_ago: int, expected: str) -> None:
@@ -106,10 +115,11 @@ class TestFormatDuration:
assert _format_duration(timestamp, self._NOW) == "unknown duration"
@pytest.mark.usefixtures("maubot_plugin")
class TestSubscribeCommand:
"""Subscribe command end-to-end via maubot."""
async def test_subscribe_valid_stream(self, maubot_test_bot, maubot_plugin) -> None:
async def test_subscribe_valid_stream(self, maubot_test_bot: TestBot) -> None:
"""Subscribe to a valid Owncast stream."""
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
with aioresponses() as mocked:
@@ -125,9 +135,7 @@ class TestSubscribeCommand:
"stream.logal.dev goes live."
)
async def test_subscribe_invalid_stream(
self, maubot_test_bot, maubot_plugin
) -> None:
async def test_subscribe_invalid_stream(self, maubot_test_bot: TestBot) -> None:
"""Reject subscription to an invalid Owncast instance."""
status_url = f"https://invalid.com{_OWNCAST_STATUS_PATH}"
with aioresponses() as mocked:
@@ -142,9 +150,7 @@ class TestSubscribeCommand:
"instance is offline."
)
async def test_subscribe_already_subscribed(
self, maubot_test_bot, maubot_plugin
) -> None:
async def test_subscribe_already_subscribed(self, maubot_test_bot: TestBot) -> None:
"""Reject duplicate subscription in the same room."""
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
with aioresponses() as mocked:
@@ -163,7 +169,7 @@ class TestSubscribeCommand:
)
async def test_subscribe_existing_stream_new_room(
self, maubot_test_bot, maubot_plugin
self, maubot_test_bot: TestBot
) -> None:
"""Skip instance validation when subscribing from a new room."""
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
@@ -189,10 +195,11 @@ class TestSubscribeCommand:
)
@pytest.mark.usefixtures("maubot_plugin")
class TestUnsubscribeCommand:
"""Unsubscribe command end-to-end via maubot."""
async def test_unsubscribe_existing(self, maubot_test_bot, maubot_plugin) -> None:
async def test_unsubscribe_existing(self, maubot_test_bot: TestBot) -> None:
"""Unsubscribe from a subscribed stream."""
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
with aioresponses() as mocked:
@@ -210,9 +217,7 @@ class TestUnsubscribeCommand:
"longer receive notifications for stream.logal.dev."
)
async def test_unsubscribe_not_subscribed(
self, maubot_test_bot, maubot_plugin
) -> None:
async def test_unsubscribe_not_subscribed(self, maubot_test_bot: TestBot) -> None:
"""Reject unsubscribe from a non-subscribed stream."""
await maubot_test_bot.send("!unsubscribe unknown.com")
@@ -222,10 +227,11 @@ class TestUnsubscribeCommand:
)
@pytest.mark.usefixtures("maubot_plugin")
class TestSubscriptionsCommand:
"""Subscriptions listing command end-to-end via maubot."""
async def test_no_subscriptions(self, maubot_test_bot, maubot_plugin) -> None:
async def test_no_subscriptions(self, maubot_test_bot: TestBot) -> None:
"""Show help text when no subscriptions exist."""
await maubot_test_bot.send("!subscriptions")
@@ -237,7 +243,9 @@ class TestSubscriptionsCommand:
)
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
async def test_shows_online_stream(self, maubot_test_bot, maubot_plugin) -> None:
async def test_shows_online_stream(
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
) -> None:
"""Show stream details including title and duration."""
# Subscribe first
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
@@ -273,7 +281,7 @@ class TestSubscriptionsCommand:
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
async def test_escapes_markdown_in_stream_name_and_title(
self, maubot_test_bot, maubot_plugin
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
) -> None:
"""Render stream name and title as literal text in command output."""
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
@@ -307,7 +315,9 @@ class TestSubscriptionsCommand:
assert "Title: `code` > quote #tag" in content.formatted_body
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
async def test_shows_offline_stream(self, maubot_test_bot, maubot_plugin) -> None:
async def test_shows_offline_stream(
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
) -> None:
"""Show offline status for non-live streams."""
# Subscribe first
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
@@ -340,7 +350,7 @@ class TestSubscriptionsCommand:
)
async def test_shows_offline_stream_without_disconnect_time(
self, maubot_test_bot, maubot_plugin
self, maubot_test_bot: TestBot
) -> None:
"""Show offline status without duration before first poll completes."""
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
@@ -364,7 +374,9 @@ class TestSubscriptionsCommand:
"instances, use `!unsubscribe <domain>`"
)
async def test_shows_unknown_stream(self, maubot_test_bot, maubot_plugin) -> None:
async def test_shows_unknown_stream(
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
) -> None:
"""Show unknown status when instance has been unreachable."""
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
with aioresponses() as mocked:
@@ -394,7 +406,7 @@ class TestSubscriptionsCommand:
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
async def test_shows_multiple_subscriptions(
self, maubot_test_bot, maubot_plugin
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
) -> None:
"""List subscriptions alphabetically with mixed statuses."""
# Subscribe in reverse alphabetical order to verify sorted output
@@ -446,10 +458,11 @@ class TestSubscriptionsCommand:
)
@pytest.mark.usefixtures("maubot_plugin")
class TestLiveCommand:
"""Live streams listing command end-to-end via maubot."""
async def test_no_subscriptions(self, maubot_test_bot, maubot_plugin) -> None:
async def test_no_subscriptions(self, maubot_test_bot: TestBot) -> None:
"""Show help text when no subscriptions exist."""
await maubot_test_bot.send("!live")
@@ -460,7 +473,9 @@ class TestLiveCommand:
"instance, use `!subscribe <domain>`"
)
async def test_no_live_streams(self, maubot_test_bot, maubot_plugin) -> None:
async def test_no_live_streams(
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
) -> None:
"""Show 'no live' message when all streams are offline."""
# Subscribe first
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
@@ -490,7 +505,9 @@ class TestLiveCommand:
)
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
async def test_shows_live_stream(self, maubot_test_bot, maubot_plugin) -> None:
async def test_shows_live_stream(
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
) -> None:
"""Show live stream with title and duration."""
# Subscribe first
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
@@ -524,7 +541,7 @@ class TestLiveCommand:
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
async def test_shows_multiple_live_streams(
self, maubot_test_bot, maubot_plugin
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
) -> None:
"""List live streams alphabetically with different durations."""
# Subscribe in reverse alphabetical order to verify sorted output
+4 -1
View File
@@ -256,6 +256,7 @@ class TestFormatMessage:
title: str,
domain: str,
tags: list[str],
*,
title_change: bool,
expected: str,
subscription_repo: SubscriptionRepository,
@@ -264,7 +265,9 @@ class TestFormatMessage:
service = _make_service(
client=_StubMatrixClient(), subscription_repo=subscription_repo
)
result = service._format_message(name, title, domain, tags, title_change)
result = service._format_message(
name, title, domain, tags, title_change=title_change
)
assert result == expected
+1 -1
View File
@@ -45,7 +45,7 @@ class _ChunkedContent:
"""Store chunks to return from iter_chunked."""
self._chunks = chunks
async def iter_chunked(self, size: int) -> AsyncIterator[bytes]:
async def iter_chunked(self, _size: int) -> AsyncIterator[bytes]:
"""Yield chunks using the interface aiohttp exposes."""
for chunk in self._chunks:
yield chunk
+84 -16
View File
@@ -28,6 +28,14 @@ from owncastsentry.notification_service import (
from owncastsentry.stream_monitor import (
_CLEANUP_DELETE_THRESHOLD,
_CLEANUP_WARNING_THRESHOLD,
_QUERY_EVERY_2_MINUTES_FAILURE_LIMIT,
_QUERY_EVERY_2_MINUTES_INTERVAL,
_QUERY_EVERY_3_MINUTES_FAILURE_LIMIT,
_QUERY_EVERY_3_MINUTES_INTERVAL,
_QUERY_EVERY_5_MINUTES_FAILURE_LIMIT,
_QUERY_EVERY_5_MINUTES_INTERVAL,
_QUERY_EVERY_15_MINUTES_INTERVAL,
_QUERY_EVERY_MINUTE_FAILURE_LIMIT,
_TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN,
StreamMonitor,
_should_query_stream,
@@ -122,25 +130,85 @@ class TestShouldQueryStream:
[
pytest.param(0, True, id="counter-0-always-query"),
pytest.param(1, True, id="counter-1-always-query"),
pytest.param(4, True, id="counter-4-always-query"),
pytest.param(5, False, id="counter-5-skip-odd"),
pytest.param(6, True, id="counter-6-query-even"),
pytest.param(9, False, id="counter-9-skip-odd"),
pytest.param(10, False, id="counter-10-skip-not-mod-3"),
pytest.param(12, True, id="counter-12-query-mod-3"),
pytest.param(14, False, id="counter-14-skip-not-mod-3"),
pytest.param(15, True, id="counter-15-query-mod-5"),
pytest.param(16, False, id="counter-16-skip-not-mod-5"),
pytest.param(20, True, id="counter-20-query-mod-5"),
pytest.param(29, False, id="counter-29-skip-not-mod-5"),
pytest.param(30, True, id="counter-30-query-mod-15"),
pytest.param(31, False, id="counter-31-skip-not-mod-15"),
pytest.param(45, True, id="counter-45-query-mod-15"),
pytest.param(
_QUERY_EVERY_MINUTE_FAILURE_LIMIT,
True,
id="last-every-minute-counter",
),
pytest.param(
_QUERY_EVERY_MINUTE_FAILURE_LIMIT + 1,
False,
id="first-two-minute-counter-skips-odd",
),
pytest.param(
_QUERY_EVERY_2_MINUTES_INTERVAL * 3,
True,
id="two-minute-tier-queries-even",
),
pytest.param(
_QUERY_EVERY_2_MINUTES_FAILURE_LIMIT,
False,
id="last-two-minute-counter-skips-odd",
),
pytest.param(
_QUERY_EVERY_2_MINUTES_FAILURE_LIMIT + 1,
False,
id="first-three-minute-counter-skips-non-multiple",
),
pytest.param(
_QUERY_EVERY_3_MINUTES_INTERVAL * 4,
True,
id="three-minute-tier-queries-multiple",
),
pytest.param(
_QUERY_EVERY_3_MINUTES_FAILURE_LIMIT,
False,
id="last-three-minute-counter-skips-non-multiple",
),
pytest.param(
_QUERY_EVERY_3_MINUTES_FAILURE_LIMIT + 1,
True,
id="first-five-minute-counter-queries-multiple",
),
pytest.param(
_QUERY_EVERY_3_MINUTES_FAILURE_LIMIT + 2,
False,
id="five-minute-tier-skips-non-multiple",
),
pytest.param(
_QUERY_EVERY_5_MINUTES_INTERVAL * 4,
True,
id="five-minute-tier-queries-multiple",
),
pytest.param(
_QUERY_EVERY_5_MINUTES_FAILURE_LIMIT,
False,
id="last-five-minute-counter-skips-non-multiple",
),
pytest.param(
_QUERY_EVERY_5_MINUTES_FAILURE_LIMIT + 1,
True,
id="first-fifteen-minute-counter-queries-multiple",
),
pytest.param(
_QUERY_EVERY_5_MINUTES_FAILURE_LIMIT + 2,
False,
id="fifteen-minute-tier-skips-non-multiple",
),
pytest.param(
_QUERY_EVERY_15_MINUTES_INTERVAL * 3,
True,
id="fifteen-minute-tier-queries-multiple",
),
pytest.param(100, False, id="counter-100-skip-not-mod-15"),
pytest.param(105, True, id="counter-105-query-mod-15"),
pytest.param(
_QUERY_EVERY_15_MINUTES_INTERVAL * 7,
True,
id="fifteen-minute-tier-later-multiple",
),
],
)
def test_backoff_tiers(self, counter: int, expected: bool) -> None:
def test_backoff_tiers(self, counter: int, *, expected: bool) -> None:
"""Return the expected query decision for each backoff tier."""
assert _should_query_stream(counter) == expected