Enabled all Ruff lint rules and resolved findings.
This commit is contained in:
@@ -31,6 +31,9 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
_MARKDOWN_ESCAPE_TABLE = str.maketrans({c: f"\\{c}" for c in r"\*_[]()~`#+-=|{}.!<>&"})
|
||||
_SECONDS_PER_MINUTE = 60
|
||||
_SECONDS_PER_HOUR = 60 * _SECONDS_PER_MINUTE
|
||||
_SECONDS_PER_DAY = 24 * _SECONDS_PER_HOUR
|
||||
|
||||
|
||||
def _sanitize_for_plain_text(text: str) -> str:
|
||||
@@ -67,18 +70,19 @@ def _format_duration(timestamp_str: str, now: datetime) -> str:
|
||||
seconds = int(delta.total_seconds())
|
||||
if seconds < 0:
|
||||
return "unknown duration"
|
||||
if seconds < 60:
|
||||
if seconds < _SECONDS_PER_MINUTE:
|
||||
return f"{seconds} second{'s' if seconds != 1 else ''}"
|
||||
if seconds < 3600:
|
||||
minutes = seconds // 60
|
||||
if seconds < _SECONDS_PER_HOUR:
|
||||
minutes = seconds // _SECONDS_PER_MINUTE
|
||||
return f"{minutes} minute{'s' if minutes != 1 else ''}"
|
||||
if seconds < 86400:
|
||||
hours = seconds // 3600
|
||||
if seconds < _SECONDS_PER_DAY:
|
||||
hours = seconds // _SECONDS_PER_HOUR
|
||||
return f"{hours} hour{'s' if hours != 1 else ''}"
|
||||
days = seconds // 86400
|
||||
return f"{days} day{'s' if days != 1 else ''}"
|
||||
except TypeError, ValueError:
|
||||
return "unknown duration"
|
||||
else:
|
||||
days = seconds // _SECONDS_PER_DAY
|
||||
return f"{days} day{'s' if days != 1 else ''}"
|
||||
|
||||
|
||||
class CommandHandler:
|
||||
|
||||
@@ -66,7 +66,8 @@ class _ResponseTimer:
|
||||
"""Mark the request as successful so the duration is recorded."""
|
||||
self._should_observe = True
|
||||
|
||||
def _finalize(self) -> None:
|
||||
def finalize(self) -> None:
|
||||
"""Record or discard the timing observation when the block exits."""
|
||||
if self._should_observe:
|
||||
self._gauge.labels(domain=self._domain).set(
|
||||
max(time.monotonic() - self._start, 0)
|
||||
@@ -196,7 +197,7 @@ class MetricsService:
|
||||
try:
|
||||
yield timer
|
||||
finally:
|
||||
timer._finalize()
|
||||
timer.finalize()
|
||||
|
||||
def set_build_info(self, version: str) -> None:
|
||||
"""Set the build version info metric.
|
||||
|
||||
@@ -101,7 +101,9 @@ class NotificationService:
|
||||
return
|
||||
|
||||
# Build the notification message
|
||||
body_text = self._format_message(name, title, domain, tags, title_change)
|
||||
body_text = self._format_message(
|
||||
name, title, domain, tags, title_change=title_change
|
||||
)
|
||||
|
||||
# Send notifications to all subscribed rooms in parallel
|
||||
successful, failed = await self._broadcast_to_rooms(domain, body_text)
|
||||
@@ -223,6 +225,7 @@ class NotificationService:
|
||||
title: str,
|
||||
domain: str,
|
||||
tags: Sequence[str],
|
||||
*,
|
||||
title_change: bool,
|
||||
) -> str:
|
||||
"""Format the notification message body.
|
||||
|
||||
@@ -15,6 +15,7 @@
|
||||
"""HTTP client for querying Owncast instance APIs."""
|
||||
|
||||
import json
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import aiohttp
|
||||
@@ -118,7 +119,7 @@ class OwncastClient:
|
||||
url = f"https://{domain}{path}"
|
||||
try:
|
||||
async with self.session.get(url, allow_redirects=False) as response:
|
||||
if response.status != 200:
|
||||
if response.status != HTTPStatus.OK:
|
||||
self.log.warning(
|
||||
"[%s] Response to request on %s was not 200, got %s instead.",
|
||||
domain,
|
||||
@@ -147,7 +148,6 @@ class OwncastClient:
|
||||
path,
|
||||
)
|
||||
return None
|
||||
return result
|
||||
except ValueError as e:
|
||||
self.log.warning(
|
||||
"[%s] Rejecting response to request on %s as could not "
|
||||
@@ -157,6 +157,8 @@ class OwncastClient:
|
||||
e,
|
||||
)
|
||||
return None
|
||||
else:
|
||||
return result
|
||||
except (aiohttp.ClientError, TimeoutError, OSError) as e:
|
||||
self.log.warning("[%s] Error making GET request to %s: %s", domain, path, e)
|
||||
return None
|
||||
@@ -237,8 +239,11 @@ class OwncastClient:
|
||||
connector = self.session.connector
|
||||
if connector is None:
|
||||
return 0
|
||||
idle = sum(len(conns) for conns in connector._conns.values())
|
||||
active = len(connector._acquired)
|
||||
|
||||
# aiohttp does not expose public live pool counts; this metric intentionally
|
||||
# mirrors its internal idle and active connection pools.
|
||||
idle = sum(len(conns) for conns in connector._conns.values()) # noqa: SLF001
|
||||
active = len(connector._acquired) # noqa: SLF001
|
||||
return idle + active
|
||||
|
||||
async def close(self) -> None:
|
||||
|
||||
@@ -33,19 +33,27 @@ _TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN = 7 * 60
|
||||
|
||||
_CLEANUP_WARNING_THRESHOLD = 83 * 24 * 60
|
||||
_CLEANUP_DELETE_THRESHOLD = 90 * 24 * 60
|
||||
_QUERY_EVERY_MINUTE_FAILURE_LIMIT = 4
|
||||
_QUERY_EVERY_2_MINUTES_FAILURE_LIMIT = 9
|
||||
_QUERY_EVERY_3_MINUTES_FAILURE_LIMIT = 14
|
||||
_QUERY_EVERY_5_MINUTES_FAILURE_LIMIT = 29
|
||||
_QUERY_EVERY_2_MINUTES_INTERVAL = 2
|
||||
_QUERY_EVERY_3_MINUTES_INTERVAL = 3
|
||||
_QUERY_EVERY_5_MINUTES_INTERVAL = 5
|
||||
_QUERY_EVERY_15_MINUTES_INTERVAL = 15
|
||||
|
||||
|
||||
def _should_query_stream(failure_counter: int) -> bool:
|
||||
"""Determine if a stream should be queried based on failure count."""
|
||||
if failure_counter <= 4:
|
||||
if failure_counter <= _QUERY_EVERY_MINUTE_FAILURE_LIMIT:
|
||||
return True
|
||||
if failure_counter <= 9:
|
||||
return failure_counter % 2 == 0
|
||||
if failure_counter <= 14:
|
||||
return failure_counter % 3 == 0
|
||||
if failure_counter <= 29:
|
||||
return failure_counter % 5 == 0
|
||||
return failure_counter % 15 == 0
|
||||
if failure_counter <= _QUERY_EVERY_2_MINUTES_FAILURE_LIMIT:
|
||||
return failure_counter % _QUERY_EVERY_2_MINUTES_INTERVAL == 0
|
||||
if failure_counter <= _QUERY_EVERY_3_MINUTES_FAILURE_LIMIT:
|
||||
return failure_counter % _QUERY_EVERY_3_MINUTES_INTERVAL == 0
|
||||
if failure_counter <= _QUERY_EVERY_5_MINUTES_FAILURE_LIMIT:
|
||||
return failure_counter % _QUERY_EVERY_5_MINUTES_INTERVAL == 0
|
||||
return failure_counter % _QUERY_EVERY_15_MINUTES_INTERVAL == 0
|
||||
|
||||
|
||||
class StreamMonitor:
|
||||
|
||||
+25
-50
@@ -42,68 +42,43 @@ python_version = "3.14"
|
||||
strict = true
|
||||
warn_unreachable = true
|
||||
explicit_package_bases = true
|
||||
exclude = ["owncastsentry/_version\\.py", "tests/"]
|
||||
# Maubot's pytest helpers are not fully typed, so strict mypy checks on
|
||||
# integration tests produce noise around fixture-provided bot/plugin objects.
|
||||
exclude = ["tests/"]
|
||||
|
||||
[tool.ruff]
|
||||
target-version = "py314"
|
||||
extend-exclude = ["owncastsentry/_version.py"] # auto-generated by hatch-vcs
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = [
|
||||
# Core
|
||||
"F", # Pyflakes
|
||||
"E", # pycodestyle errors
|
||||
"W", # pycodestyle warnings
|
||||
"N", # pep8-naming
|
||||
"D", # pydocstyle
|
||||
"I", # isort
|
||||
"ICN", # flake8-import-conventions
|
||||
|
||||
# Correctness & bugs
|
||||
"B", # flake8-bugbear
|
||||
"ASYNC", # flake8-async
|
||||
"DTZ", # flake8-datetimez
|
||||
"RSE", # flake8-raise
|
||||
"RET", # flake8-return
|
||||
"A", # flake8-builtins
|
||||
"PIE", # flake8-pie
|
||||
|
||||
# Modernization & simplification
|
||||
"UP", # pyupgrade
|
||||
"SIM", # flake8-simplify
|
||||
"C4", # flake8-comprehensions
|
||||
"FLY", # flynt (f-string conversion)
|
||||
"PTH", # flake8-use-pathlib
|
||||
|
||||
# Performance
|
||||
"PERF", # Perflint
|
||||
|
||||
# Security
|
||||
"S", # flake8-bandit
|
||||
|
||||
# Code hygiene
|
||||
"T10", # flake8-debugger
|
||||
"T20", # flake8-print
|
||||
"ERA", # eradicate
|
||||
"PGH", # pygrep-hooks
|
||||
"TC", # flake8-type-checking
|
||||
|
||||
# Testing
|
||||
"PT", # flake8-pytest-style
|
||||
|
||||
# Ruff-specific
|
||||
"RUF", # Ruff-specific rules
|
||||
]
|
||||
select = ["ALL"]
|
||||
ignore = [
|
||||
"D203", # incompatible with D211 (no blank line before class docstring)
|
||||
"D213", # incompatible with D212 (summary on first line)
|
||||
"ANN401", # Any is valid at system boundaries; mypy strict handles real issues
|
||||
"C901", # McCabe complexity: noisy and not actionable
|
||||
"COM812", # handled by the formatter
|
||||
"D203", # incompatible with D211 (no blank line before class docstring)
|
||||
"D213", # incompatible with D212 (summary on first line)
|
||||
"EM", # exception message style: inline literals are fine
|
||||
"PLR0911", # too many return statements: flat early-returns are clear
|
||||
"PLR0912", # too many branches: inherent in parsers, validators, CLI
|
||||
"PLR0913", # too many arguments: API surfaces and constructors need them
|
||||
"PLR0915", # too many statements: inherent in parsers, validators, CLI
|
||||
"TRY003", # inline exception messages are fine (complements EM ignore)
|
||||
"TRY301", # raise inside try: guard clauses don't need helper functions
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
asyncio_default_fixture_loop_scope = "function"
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
"tests/**" = ["S101"]
|
||||
"tests/**" = [
|
||||
"S101", # assert is standard for pytest
|
||||
"S311", # pseudo-random generators are fine in tests
|
||||
"SLF001", # tests legitimately access private members for verification
|
||||
"ARG001", # unused args are normal for fixtures and handler stubs
|
||||
"PLR2004", # magic values are clear in test assertions
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["owncastsentry"]
|
||||
@@ -114,4 +89,4 @@ show_missing = true
|
||||
skip_empty = true
|
||||
|
||||
[tool.codespell]
|
||||
skip = "uv.lock"
|
||||
skip = "owncastsentry/_version.py,uv.lock"
|
||||
|
||||
+8
-4
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user