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"\*_[]()~`#+-=|{}.!<>&"})
|
_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:
|
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())
|
seconds = int(delta.total_seconds())
|
||||||
if seconds < 0:
|
if seconds < 0:
|
||||||
return "unknown duration"
|
return "unknown duration"
|
||||||
if seconds < 60:
|
if seconds < _SECONDS_PER_MINUTE:
|
||||||
return f"{seconds} second{'s' if seconds != 1 else ''}"
|
return f"{seconds} second{'s' if seconds != 1 else ''}"
|
||||||
if seconds < 3600:
|
if seconds < _SECONDS_PER_HOUR:
|
||||||
minutes = seconds // 60
|
minutes = seconds // _SECONDS_PER_MINUTE
|
||||||
return f"{minutes} minute{'s' if minutes != 1 else ''}"
|
return f"{minutes} minute{'s' if minutes != 1 else ''}"
|
||||||
if seconds < 86400:
|
if seconds < _SECONDS_PER_DAY:
|
||||||
hours = seconds // 3600
|
hours = seconds // _SECONDS_PER_HOUR
|
||||||
return f"{hours} hour{'s' if hours != 1 else ''}"
|
return f"{hours} hour{'s' if hours != 1 else ''}"
|
||||||
days = seconds // 86400
|
|
||||||
return f"{days} day{'s' if days != 1 else ''}"
|
|
||||||
except TypeError, ValueError:
|
except TypeError, ValueError:
|
||||||
return "unknown duration"
|
return "unknown duration"
|
||||||
|
else:
|
||||||
|
days = seconds // _SECONDS_PER_DAY
|
||||||
|
return f"{days} day{'s' if days != 1 else ''}"
|
||||||
|
|
||||||
|
|
||||||
class CommandHandler:
|
class CommandHandler:
|
||||||
|
|||||||
@@ -66,7 +66,8 @@ class _ResponseTimer:
|
|||||||
"""Mark the request as successful so the duration is recorded."""
|
"""Mark the request as successful so the duration is recorded."""
|
||||||
self._should_observe = True
|
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:
|
if self._should_observe:
|
||||||
self._gauge.labels(domain=self._domain).set(
|
self._gauge.labels(domain=self._domain).set(
|
||||||
max(time.monotonic() - self._start, 0)
|
max(time.monotonic() - self._start, 0)
|
||||||
@@ -196,7 +197,7 @@ class MetricsService:
|
|||||||
try:
|
try:
|
||||||
yield timer
|
yield timer
|
||||||
finally:
|
finally:
|
||||||
timer._finalize()
|
timer.finalize()
|
||||||
|
|
||||||
def set_build_info(self, version: str) -> None:
|
def set_build_info(self, version: str) -> None:
|
||||||
"""Set the build version info metric.
|
"""Set the build version info metric.
|
||||||
|
|||||||
@@ -101,7 +101,9 @@ class NotificationService:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Build the notification message
|
# 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
|
# Send notifications to all subscribed rooms in parallel
|
||||||
successful, failed = await self._broadcast_to_rooms(domain, body_text)
|
successful, failed = await self._broadcast_to_rooms(domain, body_text)
|
||||||
@@ -223,6 +225,7 @@ class NotificationService:
|
|||||||
title: str,
|
title: str,
|
||||||
domain: str,
|
domain: str,
|
||||||
tags: Sequence[str],
|
tags: Sequence[str],
|
||||||
|
*,
|
||||||
title_change: bool,
|
title_change: bool,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Format the notification message body.
|
"""Format the notification message body.
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"""HTTP client for querying Owncast instance APIs."""
|
"""HTTP client for querying Owncast instance APIs."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
from http import HTTPStatus
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
@@ -118,7 +119,7 @@ class OwncastClient:
|
|||||||
url = f"https://{domain}{path}"
|
url = f"https://{domain}{path}"
|
||||||
try:
|
try:
|
||||||
async with self.session.get(url, allow_redirects=False) as response:
|
async with self.session.get(url, allow_redirects=False) as response:
|
||||||
if response.status != 200:
|
if response.status != HTTPStatus.OK:
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
"[%s] Response to request on %s was not 200, got %s instead.",
|
"[%s] Response to request on %s was not 200, got %s instead.",
|
||||||
domain,
|
domain,
|
||||||
@@ -147,7 +148,6 @@ class OwncastClient:
|
|||||||
path,
|
path,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
return result
|
|
||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
"[%s] Rejecting response to request on %s as could not "
|
"[%s] Rejecting response to request on %s as could not "
|
||||||
@@ -157,6 +157,8 @@ class OwncastClient:
|
|||||||
e,
|
e,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
else:
|
||||||
|
return result
|
||||||
except (aiohttp.ClientError, TimeoutError, OSError) as e:
|
except (aiohttp.ClientError, TimeoutError, OSError) as e:
|
||||||
self.log.warning("[%s] Error making GET request to %s: %s", domain, path, e)
|
self.log.warning("[%s] Error making GET request to %s: %s", domain, path, e)
|
||||||
return None
|
return None
|
||||||
@@ -237,8 +239,11 @@ class OwncastClient:
|
|||||||
connector = self.session.connector
|
connector = self.session.connector
|
||||||
if connector is None:
|
if connector is None:
|
||||||
return 0
|
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
|
return idle + active
|
||||||
|
|
||||||
async def close(self) -> None:
|
async def close(self) -> None:
|
||||||
|
|||||||
@@ -33,19 +33,27 @@ _TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN = 7 * 60
|
|||||||
|
|
||||||
_CLEANUP_WARNING_THRESHOLD = 83 * 24 * 60
|
_CLEANUP_WARNING_THRESHOLD = 83 * 24 * 60
|
||||||
_CLEANUP_DELETE_THRESHOLD = 90 * 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:
|
def _should_query_stream(failure_counter: int) -> bool:
|
||||||
"""Determine if a stream should be queried based on failure count."""
|
"""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
|
return True
|
||||||
if failure_counter <= 9:
|
if failure_counter <= _QUERY_EVERY_2_MINUTES_FAILURE_LIMIT:
|
||||||
return failure_counter % 2 == 0
|
return failure_counter % _QUERY_EVERY_2_MINUTES_INTERVAL == 0
|
||||||
if failure_counter <= 14:
|
if failure_counter <= _QUERY_EVERY_3_MINUTES_FAILURE_LIMIT:
|
||||||
return failure_counter % 3 == 0
|
return failure_counter % _QUERY_EVERY_3_MINUTES_INTERVAL == 0
|
||||||
if failure_counter <= 29:
|
if failure_counter <= _QUERY_EVERY_5_MINUTES_FAILURE_LIMIT:
|
||||||
return failure_counter % 5 == 0
|
return failure_counter % _QUERY_EVERY_5_MINUTES_INTERVAL == 0
|
||||||
return failure_counter % 15 == 0
|
return failure_counter % _QUERY_EVERY_15_MINUTES_INTERVAL == 0
|
||||||
|
|
||||||
|
|
||||||
class StreamMonitor:
|
class StreamMonitor:
|
||||||
|
|||||||
+25
-50
@@ -42,68 +42,43 @@ python_version = "3.14"
|
|||||||
strict = true
|
strict = true
|
||||||
warn_unreachable = true
|
warn_unreachable = true
|
||||||
explicit_package_bases = 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]
|
[tool.ruff]
|
||||||
target-version = "py314"
|
target-version = "py314"
|
||||||
extend-exclude = ["owncastsentry/_version.py"] # auto-generated by hatch-vcs
|
extend-exclude = ["owncastsentry/_version.py"] # auto-generated by hatch-vcs
|
||||||
|
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
select = [
|
select = ["ALL"]
|
||||||
# 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
|
|
||||||
]
|
|
||||||
ignore = [
|
ignore = [
|
||||||
"D203", # incompatible with D211 (no blank line before class docstring)
|
"ANN401", # Any is valid at system boundaries; mypy strict handles real issues
|
||||||
"D213", # incompatible with D212 (summary on first line)
|
"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]
|
[tool.pytest.ini_options]
|
||||||
asyncio_mode = "auto"
|
asyncio_mode = "auto"
|
||||||
|
asyncio_default_fixture_loop_scope = "function"
|
||||||
|
|
||||||
[tool.ruff.lint.per-file-ignores]
|
[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]
|
[tool.coverage.run]
|
||||||
source = ["owncastsentry"]
|
source = ["owncastsentry"]
|
||||||
@@ -114,4 +89,4 @@ show_missing = true
|
|||||||
skip_empty = true
|
skip_empty = true
|
||||||
|
|
||||||
[tool.codespell]
|
[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 collections.abc import AsyncIterator
|
||||||
from pathlib import Path
|
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.metrics import MetricsService
|
||||||
from owncastsentry.types import StreamConfig, StreamState
|
from owncastsentry.types import StreamConfig, StreamState
|
||||||
|
|
||||||
@@ -72,19 +76,19 @@ def subscription_repo(database: Database) -> SubscriptionRepository:
|
|||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def maubot_plugin_class():
|
def maubot_plugin_class() -> type[Plugin]:
|
||||||
"""Use OwncastSentry as the plugin class for maubot integration tests."""
|
"""Use OwncastSentry as the plugin class for maubot integration tests."""
|
||||||
return OwncastSentry
|
return OwncastSentry
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def maubot_plugin_config_class():
|
def maubot_plugin_config_class() -> type[BaseProxyConfig]:
|
||||||
"""Use our Config class for maubot integration tests."""
|
"""Use our Config class for maubot integration tests."""
|
||||||
return Config
|
return Config
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def maubot_upgrade_table():
|
def maubot_upgrade_table() -> UpgradeTable:
|
||||||
"""Provide the database upgrade table for maubot integration tests."""
|
"""Provide the database upgrade table for maubot integration tests."""
|
||||||
return get_upgrade_table()
|
return get_upgrade_table()
|
||||||
|
|
||||||
@@ -220,7 +224,7 @@ class _StubOwncastClient:
|
|||||||
self.queried_domains.append(domain)
|
self.queried_domains.append(domain)
|
||||||
return self.stream_state
|
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."""
|
"""Return the configured stream config."""
|
||||||
self.config_call_count += 1
|
self.config_call_count += 1
|
||||||
return self.stream_config
|
return self.stream_config
|
||||||
|
|||||||
+46
-29
@@ -16,12 +16,16 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import time_machine
|
import time_machine
|
||||||
from aioresponses import aioresponses
|
from aioresponses import aioresponses
|
||||||
|
|
||||||
from owncastsentry.commands import (
|
from owncastsentry.commands import (
|
||||||
|
_SECONDS_PER_DAY,
|
||||||
|
_SECONDS_PER_HOUR,
|
||||||
|
_SECONDS_PER_MINUTE,
|
||||||
_escape_markdown,
|
_escape_markdown,
|
||||||
_format_duration,
|
_format_duration,
|
||||||
_sanitize_for_markdown,
|
_sanitize_for_markdown,
|
||||||
@@ -30,6 +34,11 @@ from owncastsentry.owncast_client import _OWNCAST_STATUS_PATH
|
|||||||
from owncastsentry.types import UNKNOWN_STATUS_THRESHOLD, StreamState
|
from owncastsentry.types import UNKNOWN_STATUS_THRESHOLD, StreamState
|
||||||
from tests.conftest import VALID_STATUS_RESPONSE
|
from tests.conftest import VALID_STATUS_RESPONSE
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from maubot.testing.bot import TestBot
|
||||||
|
|
||||||
|
from owncastsentry import OwncastSentry
|
||||||
|
|
||||||
|
|
||||||
class TestEscapeMarkdown:
|
class TestEscapeMarkdown:
|
||||||
"""Markdown special character escaping."""
|
"""Markdown special character escaping."""
|
||||||
@@ -78,12 +87,12 @@ class TestFormatDuration:
|
|||||||
[
|
[
|
||||||
pytest.param(1, "1 second", id="singular-second"),
|
pytest.param(1, "1 second", id="singular-second"),
|
||||||
pytest.param(30, "30 seconds", id="plural-seconds"),
|
pytest.param(30, "30 seconds", id="plural-seconds"),
|
||||||
pytest.param(60, "1 minute", id="singular-minute"),
|
pytest.param(_SECONDS_PER_MINUTE, "1 minute", id="singular-minute"),
|
||||||
pytest.param(120, "2 minutes", id="plural-minutes"),
|
pytest.param(2 * _SECONDS_PER_MINUTE, "2 minutes", id="plural-minutes"),
|
||||||
pytest.param(3600, "1 hour", id="singular-hour"),
|
pytest.param(_SECONDS_PER_HOUR, "1 hour", id="singular-hour"),
|
||||||
pytest.param(7200, "2 hours", id="plural-hours"),
|
pytest.param(2 * _SECONDS_PER_HOUR, "2 hours", id="plural-hours"),
|
||||||
pytest.param(86400, "1 day", id="singular-day"),
|
pytest.param(_SECONDS_PER_DAY, "1 day", id="singular-day"),
|
||||||
pytest.param(172800, "2 days", id="plural-days"),
|
pytest.param(2 * _SECONDS_PER_DAY, "2 days", id="plural-days"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_formats_duration(self, seconds_ago: int, expected: str) -> None:
|
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"
|
assert _format_duration(timestamp, self._NOW) == "unknown duration"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("maubot_plugin")
|
||||||
class TestSubscribeCommand:
|
class TestSubscribeCommand:
|
||||||
"""Subscribe command end-to-end via maubot."""
|
"""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."""
|
"""Subscribe to a valid Owncast stream."""
|
||||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||||
with aioresponses() as mocked:
|
with aioresponses() as mocked:
|
||||||
@@ -125,9 +135,7 @@ class TestSubscribeCommand:
|
|||||||
"stream.logal.dev goes live."
|
"stream.logal.dev goes live."
|
||||||
)
|
)
|
||||||
|
|
||||||
async def test_subscribe_invalid_stream(
|
async def test_subscribe_invalid_stream(self, maubot_test_bot: TestBot) -> None:
|
||||||
self, maubot_test_bot, maubot_plugin
|
|
||||||
) -> None:
|
|
||||||
"""Reject subscription to an invalid Owncast instance."""
|
"""Reject subscription to an invalid Owncast instance."""
|
||||||
status_url = f"https://invalid.com{_OWNCAST_STATUS_PATH}"
|
status_url = f"https://invalid.com{_OWNCAST_STATUS_PATH}"
|
||||||
with aioresponses() as mocked:
|
with aioresponses() as mocked:
|
||||||
@@ -142,9 +150,7 @@ class TestSubscribeCommand:
|
|||||||
"instance is offline."
|
"instance is offline."
|
||||||
)
|
)
|
||||||
|
|
||||||
async def test_subscribe_already_subscribed(
|
async def test_subscribe_already_subscribed(self, maubot_test_bot: TestBot) -> None:
|
||||||
self, maubot_test_bot, maubot_plugin
|
|
||||||
) -> None:
|
|
||||||
"""Reject duplicate subscription in the same room."""
|
"""Reject duplicate subscription in the same room."""
|
||||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||||
with aioresponses() as mocked:
|
with aioresponses() as mocked:
|
||||||
@@ -163,7 +169,7 @@ class TestSubscribeCommand:
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def test_subscribe_existing_stream_new_room(
|
async def test_subscribe_existing_stream_new_room(
|
||||||
self, maubot_test_bot, maubot_plugin
|
self, maubot_test_bot: TestBot
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Skip instance validation when subscribing from a new room."""
|
"""Skip instance validation when subscribing from a new room."""
|
||||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||||
@@ -189,10 +195,11 @@ class TestSubscribeCommand:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("maubot_plugin")
|
||||||
class TestUnsubscribeCommand:
|
class TestUnsubscribeCommand:
|
||||||
"""Unsubscribe command end-to-end via maubot."""
|
"""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."""
|
"""Unsubscribe from a subscribed stream."""
|
||||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||||
with aioresponses() as mocked:
|
with aioresponses() as mocked:
|
||||||
@@ -210,9 +217,7 @@ class TestUnsubscribeCommand:
|
|||||||
"longer receive notifications for stream.logal.dev."
|
"longer receive notifications for stream.logal.dev."
|
||||||
)
|
)
|
||||||
|
|
||||||
async def test_unsubscribe_not_subscribed(
|
async def test_unsubscribe_not_subscribed(self, maubot_test_bot: TestBot) -> None:
|
||||||
self, maubot_test_bot, maubot_plugin
|
|
||||||
) -> None:
|
|
||||||
"""Reject unsubscribe from a non-subscribed stream."""
|
"""Reject unsubscribe from a non-subscribed stream."""
|
||||||
await maubot_test_bot.send("!unsubscribe unknown.com")
|
await maubot_test_bot.send("!unsubscribe unknown.com")
|
||||||
|
|
||||||
@@ -222,10 +227,11 @@ class TestUnsubscribeCommand:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("maubot_plugin")
|
||||||
class TestSubscriptionsCommand:
|
class TestSubscriptionsCommand:
|
||||||
"""Subscriptions listing command end-to-end via maubot."""
|
"""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."""
|
"""Show help text when no subscriptions exist."""
|
||||||
await maubot_test_bot.send("!subscriptions")
|
await maubot_test_bot.send("!subscriptions")
|
||||||
|
|
||||||
@@ -237,7 +243,9 @@ class TestSubscriptionsCommand:
|
|||||||
)
|
)
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
|
@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."""
|
"""Show stream details including title and duration."""
|
||||||
# Subscribe first
|
# Subscribe first
|
||||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
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))
|
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
|
||||||
async def test_escapes_markdown_in_stream_name_and_title(
|
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:
|
) -> None:
|
||||||
"""Render stream name and title as literal text in command output."""
|
"""Render stream name and title as literal text in command output."""
|
||||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
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
|
assert "Title: `code` > quote #tag" in content.formatted_body
|
||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
|
@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."""
|
"""Show offline status for non-live streams."""
|
||||||
# Subscribe first
|
# Subscribe first
|
||||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
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(
|
async def test_shows_offline_stream_without_disconnect_time(
|
||||||
self, maubot_test_bot, maubot_plugin
|
self, maubot_test_bot: TestBot
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Show offline status without duration before first poll completes."""
|
"""Show offline status without duration before first poll completes."""
|
||||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||||
@@ -364,7 +374,9 @@ class TestSubscriptionsCommand:
|
|||||||
"instances, use `!unsubscribe <domain>`"
|
"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."""
|
"""Show unknown status when instance has been unreachable."""
|
||||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
||||||
with aioresponses() as mocked:
|
with aioresponses() as mocked:
|
||||||
@@ -394,7 +406,7 @@ class TestSubscriptionsCommand:
|
|||||||
|
|
||||||
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
|
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
|
||||||
async def test_shows_multiple_subscriptions(
|
async def test_shows_multiple_subscriptions(
|
||||||
self, maubot_test_bot, maubot_plugin
|
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
|
||||||
) -> None:
|
) -> None:
|
||||||
"""List subscriptions alphabetically with mixed statuses."""
|
"""List subscriptions alphabetically with mixed statuses."""
|
||||||
# Subscribe in reverse alphabetical order to verify sorted output
|
# Subscribe in reverse alphabetical order to verify sorted output
|
||||||
@@ -446,10 +458,11 @@ class TestSubscriptionsCommand:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.usefixtures("maubot_plugin")
|
||||||
class TestLiveCommand:
|
class TestLiveCommand:
|
||||||
"""Live streams listing command end-to-end via maubot."""
|
"""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."""
|
"""Show help text when no subscriptions exist."""
|
||||||
await maubot_test_bot.send("!live")
|
await maubot_test_bot.send("!live")
|
||||||
|
|
||||||
@@ -460,7 +473,9 @@ class TestLiveCommand:
|
|||||||
"instance, use `!subscribe <domain>`"
|
"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."""
|
"""Show 'no live' message when all streams are offline."""
|
||||||
# Subscribe first
|
# Subscribe first
|
||||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
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))
|
@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."""
|
"""Show live stream with title and duration."""
|
||||||
# Subscribe first
|
# Subscribe first
|
||||||
status_url = f"https://stream.logal.dev{_OWNCAST_STATUS_PATH}"
|
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))
|
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
|
||||||
async def test_shows_multiple_live_streams(
|
async def test_shows_multiple_live_streams(
|
||||||
self, maubot_test_bot, maubot_plugin
|
self, maubot_test_bot: TestBot, maubot_plugin: OwncastSentry
|
||||||
) -> None:
|
) -> None:
|
||||||
"""List live streams alphabetically with different durations."""
|
"""List live streams alphabetically with different durations."""
|
||||||
# Subscribe in reverse alphabetical order to verify sorted output
|
# Subscribe in reverse alphabetical order to verify sorted output
|
||||||
|
|||||||
@@ -256,6 +256,7 @@ class TestFormatMessage:
|
|||||||
title: str,
|
title: str,
|
||||||
domain: str,
|
domain: str,
|
||||||
tags: list[str],
|
tags: list[str],
|
||||||
|
*,
|
||||||
title_change: bool,
|
title_change: bool,
|
||||||
expected: str,
|
expected: str,
|
||||||
subscription_repo: SubscriptionRepository,
|
subscription_repo: SubscriptionRepository,
|
||||||
@@ -264,7 +265,9 @@ class TestFormatMessage:
|
|||||||
service = _make_service(
|
service = _make_service(
|
||||||
client=_StubMatrixClient(), subscription_repo=subscription_repo
|
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
|
assert result == expected
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ class _ChunkedContent:
|
|||||||
"""Store chunks to return from iter_chunked."""
|
"""Store chunks to return from iter_chunked."""
|
||||||
self._chunks = chunks
|
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."""
|
"""Yield chunks using the interface aiohttp exposes."""
|
||||||
for chunk in self._chunks:
|
for chunk in self._chunks:
|
||||||
yield chunk
|
yield chunk
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ from owncastsentry.notification_service import (
|
|||||||
from owncastsentry.stream_monitor import (
|
from owncastsentry.stream_monitor import (
|
||||||
_CLEANUP_DELETE_THRESHOLD,
|
_CLEANUP_DELETE_THRESHOLD,
|
||||||
_CLEANUP_WARNING_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,
|
_TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN,
|
||||||
StreamMonitor,
|
StreamMonitor,
|
||||||
_should_query_stream,
|
_should_query_stream,
|
||||||
@@ -122,25 +130,85 @@ class TestShouldQueryStream:
|
|||||||
[
|
[
|
||||||
pytest.param(0, True, id="counter-0-always-query"),
|
pytest.param(0, True, id="counter-0-always-query"),
|
||||||
pytest.param(1, True, id="counter-1-always-query"),
|
pytest.param(1, True, id="counter-1-always-query"),
|
||||||
pytest.param(4, True, id="counter-4-always-query"),
|
pytest.param(
|
||||||
pytest.param(5, False, id="counter-5-skip-odd"),
|
_QUERY_EVERY_MINUTE_FAILURE_LIMIT,
|
||||||
pytest.param(6, True, id="counter-6-query-even"),
|
True,
|
||||||
pytest.param(9, False, id="counter-9-skip-odd"),
|
id="last-every-minute-counter",
|
||||||
pytest.param(10, False, id="counter-10-skip-not-mod-3"),
|
),
|
||||||
pytest.param(12, True, id="counter-12-query-mod-3"),
|
pytest.param(
|
||||||
pytest.param(14, False, id="counter-14-skip-not-mod-3"),
|
_QUERY_EVERY_MINUTE_FAILURE_LIMIT + 1,
|
||||||
pytest.param(15, True, id="counter-15-query-mod-5"),
|
False,
|
||||||
pytest.param(16, False, id="counter-16-skip-not-mod-5"),
|
id="first-two-minute-counter-skips-odd",
|
||||||
pytest.param(20, True, id="counter-20-query-mod-5"),
|
),
|
||||||
pytest.param(29, False, id="counter-29-skip-not-mod-5"),
|
pytest.param(
|
||||||
pytest.param(30, True, id="counter-30-query-mod-15"),
|
_QUERY_EVERY_2_MINUTES_INTERVAL * 3,
|
||||||
pytest.param(31, False, id="counter-31-skip-not-mod-15"),
|
True,
|
||||||
pytest.param(45, True, id="counter-45-query-mod-15"),
|
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(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."""
|
"""Return the expected query decision for each backoff tier."""
|
||||||
assert _should_query_stream(counter) == expected
|
assert _should_query_stream(counter) == expected
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user