Compare commits
11
Commits
v1.2.0
...
38e91add32
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38e91add32
|
||
|
|
c837b916c0
|
||
|
|
4d0ae3d1ea
|
||
|
|
2f6d0bc1ed
|
||
|
|
d905740755
|
||
|
|
795184cd17
|
||
|
|
48a4fb9bbf
|
||
|
|
ec42266af3
|
||
|
|
33cc434354
|
||
|
|
0620c675d9
|
||
|
|
179d087e33
|
@@ -0,0 +1,390 @@
|
|||||||
|
# OwncastSentry Package
|
||||||
|
|
||||||
|
This package contains the Maubot plugin runtime code for OwncastSentry. It
|
||||||
|
tracks room subscriptions to Owncast instances, polls subscribed streams,
|
||||||
|
detects stream state changes, and sends Matrix notifications.
|
||||||
|
|
||||||
|
This README gives maintainers a high-level map of how the package fits
|
||||||
|
together and where the important behavior lives. User-facing setup and command
|
||||||
|
usage belong outside this package. Function-level details belong in docstrings
|
||||||
|
or tests.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `__init__.py`: Maubot plugin entry point, service wiring, command
|
||||||
|
decorators, scheduler loop, shutdown, and metrics endpoint.
|
||||||
|
- `config.py`: Maubot configuration proxy for the Prometheus metrics endpoint
|
||||||
|
setting.
|
||||||
|
- `types.py`: immutable value objects, Owncast API response validation, derived
|
||||||
|
stream status, update results, and subscription domain errors.
|
||||||
|
- `repository.py`: database schema migrations plus stream and subscription
|
||||||
|
repositories.
|
||||||
|
- `owncast_client.py`: bounded aiohttp client for Owncast `/api/status` and
|
||||||
|
`/api/config` requests.
|
||||||
|
- `subscription_manager.py`: subscription use cases, domain normalization, and
|
||||||
|
first-subscription Owncast validation.
|
||||||
|
- `stream_monitor.py`: stream update orchestration, state transition detection,
|
||||||
|
progressive failure backoff, cleanup thresholds, notification decisions, and
|
||||||
|
stream metrics.
|
||||||
|
- `notification_service.py`: Matrix notification formatting, broadcast,
|
||||||
|
per-domain notification cooldowns, cleanup notices, and delivery metrics.
|
||||||
|
- `commands.py`: Maubot command handlers for subscribe, unsubscribe,
|
||||||
|
subscriptions, and live listings.
|
||||||
|
- `metrics.py`: isolated Prometheus registry, counters, gauges, and response
|
||||||
|
timing helpers.
|
||||||
|
|
||||||
|
The package has a service-layer shape: `OwncastSentry.start()` builds the
|
||||||
|
services, commands call `CommandHandler`, command handlers call
|
||||||
|
`SubscriptionManager`, scheduled updates call `StreamMonitor`, and
|
||||||
|
`StreamMonitor` coordinates repositories, the Owncast client, notifications,
|
||||||
|
and metrics.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
Maubot["Maubot loads OwncastSentry"] --> Start["OwncastSentry.start()"]
|
||||||
|
Start --> Repos["StreamRepository and SubscriptionRepository"]
|
||||||
|
Start --> Owncast["OwncastClient"]
|
||||||
|
Start --> Metrics["MetricsService"]
|
||||||
|
Start --> Notify["NotificationService"]
|
||||||
|
Start --> Monitor["StreamMonitor"]
|
||||||
|
Start --> Manager["SubscriptionManager"]
|
||||||
|
Start --> Commands["CommandHandler"]
|
||||||
|
Commands --> Manager
|
||||||
|
Manager --> Repos
|
||||||
|
Manager --> Owncast
|
||||||
|
Monitor --> Repos
|
||||||
|
Monitor --> Owncast
|
||||||
|
Monitor --> Notify
|
||||||
|
Monitor --> Metrics
|
||||||
|
Notify --> Repos
|
||||||
|
Notify --> Matrix["Matrix client"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Core Concepts
|
||||||
|
|
||||||
|
A few terms are used throughout the package:
|
||||||
|
|
||||||
|
- A stream is a normalized Owncast domain tracked by the plugin. The database
|
||||||
|
stores one stream row per domain, even if multiple Matrix rooms subscribe to
|
||||||
|
it.
|
||||||
|
- A subscription is a Matrix room's request to receive notifications for one
|
||||||
|
stream domain.
|
||||||
|
- `StreamStateObservation` is one successful Owncast status API sample. The
|
||||||
|
monitor decides whether the observation starts a new persisted status period.
|
||||||
|
- `StreamConfigObservation` is metadata fetched from Owncast's config API,
|
||||||
|
currently the instance name and tags used in notifications and periodic
|
||||||
|
display-name refreshes.
|
||||||
|
- `StreamState` is an immutable snapshot of the latest persisted state for a
|
||||||
|
stream: display metadata, the latest Owncast online flag, when that status was
|
||||||
|
first observed by the bot, failure count, and the derived online, offline, or
|
||||||
|
unknown status.
|
||||||
|
|
||||||
|
Durable state lives in the database. The `streams` table stores the latest
|
||||||
|
known display and state fields for each Owncast domain, and the `subscriptions`
|
||||||
|
table stores which Matrix rooms follow each domain.
|
||||||
|
|
||||||
|
Subscriptions are room-scoped, while stream state is shared per domain.
|
||||||
|
Notification delivery fans out from one stored stream state to the rooms
|
||||||
|
currently subscribed to that domain.
|
||||||
|
|
||||||
|
Stream identity is the normalized bare domain. Repositories, metrics,
|
||||||
|
notification state, and Owncast API calls all use that domain rather than the
|
||||||
|
original user-supplied URL.
|
||||||
|
|
||||||
|
Failures stay isolated at plugin boundaries. A bad command, failed stream
|
||||||
|
check, or room delivery is logged and counted without stopping unrelated
|
||||||
|
commands, stream updates, or notifications.
|
||||||
|
|
||||||
|
`StreamStateObservation`, `StreamConfigObservation`, `StreamState`,
|
||||||
|
`UpdateResult`, and `RoomSubscription` are immutable snapshots. State changes
|
||||||
|
are represented by new values that are persisted through repositories instead of
|
||||||
|
mutating existing objects.
|
||||||
|
|
||||||
|
## Service Wiring
|
||||||
|
|
||||||
|
`OwncastSentry.start()` is the composition root for one plugin instance. It
|
||||||
|
loads config, creates the metrics service, Owncast API client, repositories,
|
||||||
|
notification service, stream monitor, subscription manager, and command handler,
|
||||||
|
then passes dependencies into each service explicitly.
|
||||||
|
|
||||||
|
This keeps lower-level modules independent of Maubot globals. Commands do not
|
||||||
|
construct repositories, `StreamMonitor` does not know about the Maubot
|
||||||
|
scheduler, and notification delivery does not fetch its own subscription state
|
||||||
|
from the plugin instance.
|
||||||
|
|
||||||
|
The plugin boundary owns scheduling. The minute loop loads subscribed domains
|
||||||
|
from `SubscriptionRepository` and calls `StreamMonitor.update_all_streams()`.
|
||||||
|
The same pattern applies to new background work: schedule it from the plugin
|
||||||
|
boundary, keep the worker service callable directly, and close owned external
|
||||||
|
resources from `OwncastSentry.stop()`.
|
||||||
|
|
||||||
|
## Subscription Management
|
||||||
|
|
||||||
|
`SubscriptionManager` coordinates the room-to-domain relationship. Command
|
||||||
|
handlers pass it user-supplied stream targets, and it turns those targets into
|
||||||
|
normalized domains before touching repository state.
|
||||||
|
|
||||||
|
Subscribing a room creates a subscription for the normalized domain and creates
|
||||||
|
the shared stream row when the domain is first seen. First-time domains are
|
||||||
|
validated through `OwncastClient`; domains that already have subscribers reuse
|
||||||
|
the existing stream record instead of revalidating.
|
||||||
|
|
||||||
|
Unsubscribing removes one room's subscription to a domain. It does not delete
|
||||||
|
the shared stream row or make remote Owncast requests. Long-term dead stream
|
||||||
|
cleanup is owned by `StreamMonitor`.
|
||||||
|
|
||||||
|
Listing methods stay room-scoped. They return the subscriptions relevant to one
|
||||||
|
Matrix room, with the shared stream state attached for display.
|
||||||
|
|
||||||
|
## Owncast API Client
|
||||||
|
|
||||||
|
`OwncastClient` owns the aiohttp session used for Owncast API requests. It
|
||||||
|
fetches stream status and instance config, validates response shape, and turns
|
||||||
|
accepted responses into the value objects used by the rest of the package.
|
||||||
|
|
||||||
|
The session uses a plugin-specific User-Agent, a dummy cookie jar, no DNS cache,
|
||||||
|
a global connection limit, a per-host connection limit of one, and connect/read
|
||||||
|
socket timeouts. aiohttp's own DNS cache is disabled so DNS caching and policy
|
||||||
|
stay with the resolver configured for the deployment.
|
||||||
|
|
||||||
|
The per-host connection limit and keepalive settings are intentional. Each
|
||||||
|
Owncast instance gets at most one reusable connection, so minute-by-minute
|
||||||
|
status checks can reuse the existing TLS session instead of opening a fresh
|
||||||
|
connection for every poll.
|
||||||
|
|
||||||
|
Responses are accepted only when they are HTTP 200 JSON objects under the
|
||||||
|
configured size limit. Invalid JSON, non-object JSON, oversized bodies,
|
||||||
|
unexpected status codes, malformed API fields, connection errors, and timeouts
|
||||||
|
are logged and ignored.
|
||||||
|
|
||||||
|
The response-time metric is recorded only when a request completes and the
|
||||||
|
parsed response shape is valid. Failed requests remove any stale timing label
|
||||||
|
for that domain.
|
||||||
|
|
||||||
|
## Stream Monitoring
|
||||||
|
|
||||||
|
`StreamMonitor` owns the core per-domain state machine. `update_stream()`
|
||||||
|
compares the stored stream state with the latest Owncast status response,
|
||||||
|
decides whether to send a notification, updates persistent state when needed,
|
||||||
|
and records stream metrics. Each poll first handles missing rows, polling
|
||||||
|
backoff, and failed Owncast requests. Only a valid status response enters the
|
||||||
|
stream transition policy. `update_all_streams()` wraps this flow for many
|
||||||
|
domains, isolates per-domain exceptions, and records subscription-count metrics.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Start["update_stream(domain)"] --> Old["load stored StreamState"]
|
||||||
|
Old --> Missing{"row exists?"}
|
||||||
|
Missing -->|no| Noop["return success"]
|
||||||
|
Missing -->|yes| Backoff{"backoff allows poll?"}
|
||||||
|
Backoff -->|no| Skip["skip HTTP call<br/>increment failure counter"]
|
||||||
|
Skip --> CleanupSkip["run warning/deletion cleanup checks"]
|
||||||
|
CleanupSkip --> MetricsSkip["if row remains, update<br/>status and failure metrics"]
|
||||||
|
MetricsSkip --> SuccessSkip["return success"]
|
||||||
|
Backoff -->|yes| Fetch["fetch and validate /api/status"]
|
||||||
|
Fetch --> Fetched{"got valid state?"}
|
||||||
|
Fetched -->|no| Failure["increment failure counter"]
|
||||||
|
Failure --> CleanupFail["run warning/deletion cleanup checks"]
|
||||||
|
CleanupFail --> MetricsFail["if row remains, update<br/>status and failure metrics"]
|
||||||
|
MetricsFail --> Failed["return failure"]
|
||||||
|
Fetched -->|yes| Reset["reset failure counter and metric"]
|
||||||
|
Reset --> Timer["ensure offline timer<br/>cache entry exists"]
|
||||||
|
Timer --> Transition["classify stream transition"]
|
||||||
|
Transition --> First{"first observation?"}
|
||||||
|
First -->|yes| FirstLog["log suppressed notification"]
|
||||||
|
First -->|no| LiveCheck{"went live?"}
|
||||||
|
FirstLog --> ConfigDecision
|
||||||
|
LiveCheck -->|yes| Brief{"observed offline less than<br/>temporary cooldown?"}
|
||||||
|
LiveCheck -->|no| TitleChanged{"title changed<br/>while online?"}
|
||||||
|
Brief -->|no| LiveNotify["go-live notification needed"]
|
||||||
|
Brief -->|yes| BriefTitle{"title changed?"}
|
||||||
|
BriefTitle -->|yes| BriefNotify["title-change notification needed"]
|
||||||
|
BriefTitle -->|no| NoNotify["no notification"]
|
||||||
|
TitleChanged -->|yes| OfflineMarker{"offline marker newer than<br/>last notification?"}
|
||||||
|
TitleChanged -->|no| WentOffline{"went offline?"}
|
||||||
|
WentOffline -->|yes| Offline["record offline timer"]
|
||||||
|
WentOffline -->|no| NoNotify
|
||||||
|
OfflineMarker -->|yes| ResumeNotify["go-live notification needed"]
|
||||||
|
OfflineMarker -->|no| TitleNotify["title-change notification needed"]
|
||||||
|
NoNotify --> ConfigDecision
|
||||||
|
Offline --> ConfigDecision
|
||||||
|
LiveNotify --> ConfigDecision
|
||||||
|
BriefNotify --> ConfigDecision
|
||||||
|
ResumeNotify --> ConfigDecision
|
||||||
|
TitleNotify --> ConfigDecision
|
||||||
|
ConfigDecision{"notification, first observation,<br/>or hourly refresh?"}
|
||||||
|
ConfigDecision -->|yes| Config["fetch /api/config"]
|
||||||
|
ConfigDecision -->|no| NotifyGate{"notification needed?"}
|
||||||
|
Config --> NotifyGate
|
||||||
|
NotifyGate -->|yes| Send["attempt live/title notification"]
|
||||||
|
NotifyGate -->|no| Save["save stream row<br/>repository ignores unchanged values"]
|
||||||
|
Send --> Save
|
||||||
|
Save --> FinalMetrics["set current status metric"]
|
||||||
|
FinalMetrics --> Done["return success"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Failure counters drive both status and polling behavior. A stream is displayed
|
||||||
|
as unknown when its failure counter is above `UNKNOWN_STATUS_THRESHOLD`.
|
||||||
|
Counters 0 through 4 poll every minute; later tiers poll only on selected
|
||||||
|
counter values and eventually only every fifteenth minute. Skipped cycles still
|
||||||
|
increment the counter so cleanup can progress.
|
||||||
|
|
||||||
|
Transition decisions use Owncast's `online` field. The stream status timestamp is
|
||||||
|
the bot's local UTC observation time for the first successful poll, a missing
|
||||||
|
timestamp repair, or an online/offline transition. Owncast's remote connect,
|
||||||
|
disconnect, and server timestamps are not required for status parsing.
|
||||||
|
|
||||||
|
First observation is special. If a new stream row has no status timestamp, the
|
||||||
|
first successful update fetches config and stores state but suppresses
|
||||||
|
notification attempts, even if the stream is already live.
|
||||||
|
|
||||||
|
Go-live notification attempts happen when a stream moves from offline to online
|
||||||
|
and the monitor's in-memory offline timer shows it was not merely a brief
|
||||||
|
outage. A stream that returns within the temporary offline cooldown only attempts
|
||||||
|
a notification when the title changed, and then it uses a title change
|
||||||
|
notification.
|
||||||
|
|
||||||
|
Mid-session title changes attempt title change notifications. If the monitor has
|
||||||
|
an offline marker that is newer than the last notification, it attempts a normal
|
||||||
|
live notification instead so rooms do not see a title change for what is
|
||||||
|
effectively a new session.
|
||||||
|
|
||||||
|
Offline transitions do not notify rooms. They update the database and record a
|
||||||
|
monotonic offline timestamp used by later live/title notification policy.
|
||||||
|
|
||||||
|
The live/title notification send step is an attempt, not a guaranteed Matrix message.
|
||||||
|
`NotificationService` still applies the shared live/title cooldown and records
|
||||||
|
the cooldown only when at least one room receives the message.
|
||||||
|
|
||||||
|
`/api/config` is fetched when the monitor is about to attempt a live or
|
||||||
|
title-change notification, on a stream's first successful observation, and
|
||||||
|
during the hourly refresh window for successfully polled streams. The config
|
||||||
|
response supplies the display name and tags for notifications. If config is
|
||||||
|
fetched successfully during an update, the persisted stream name is refreshed
|
||||||
|
from it; otherwise the stored name is left unchanged.
|
||||||
|
|
||||||
|
## Cleanup Policy
|
||||||
|
|
||||||
|
A domain that remains unreachable is eventually cleaned up. The failure counter
|
||||||
|
represents minute ticks, including skipped backoff ticks.
|
||||||
|
|
||||||
|
At the 83-day threshold, `StreamMonitor` sends a cleanup warning to all rooms
|
||||||
|
subscribed to the domain. At the 90-day threshold or beyond, it sends a deletion
|
||||||
|
notice, deletes all subscriptions for the domain, deletes the stream row, clears
|
||||||
|
the monitor and notification caches for that domain, and removes per-domain
|
||||||
|
metrics.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Counter["failure counter after failed or skipped check"] --> Warn{"counter == 83 days?"}
|
||||||
|
Warn -->|yes| Warning["send cleanup warning"]
|
||||||
|
Warn -->|no| Delete
|
||||||
|
Warning --> Delete{"counter >= 90 days?"}
|
||||||
|
Delete -->|no| Done["done"]
|
||||||
|
Delete -->|yes| Notice["send cleanup deletion notice"]
|
||||||
|
Notice --> Subs["delete subscriptions"]
|
||||||
|
Subs --> Stream["delete stream row"]
|
||||||
|
Stream --> Caches["clear local caches"]
|
||||||
|
Caches --> Metrics["remove per-domain metrics"]
|
||||||
|
Metrics --> DoneCleanup["done"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notification Delivery
|
||||||
|
|
||||||
|
`NotificationService` sends plain text Matrix messages to every room subscribed
|
||||||
|
to a domain. Broadcasts run concurrently and use `asyncio.gather(...,
|
||||||
|
return_exceptions=True)` so one failed room does not block delivery to other
|
||||||
|
rooms.
|
||||||
|
|
||||||
|
Live and title-change notifications share a per-domain cooldown. The cooldown
|
||||||
|
is recorded only when at least one room receives the message. If there are no
|
||||||
|
subscribed rooms, or every delivery fails, no cooldown is recorded.
|
||||||
|
|
||||||
|
Notification text is whitespace-normalized before sending. Stream names fall
|
||||||
|
back to the domain when config lookup fails or the instance name is empty. Tags
|
||||||
|
are appended as hash tags after filtering out empty tags and tags that start
|
||||||
|
with a dot.
|
||||||
|
|
||||||
|
Cleanup warning and deletion notices bypass the live/title cooldown, but still
|
||||||
|
record delivery metrics.
|
||||||
|
|
||||||
|
## Command Handling
|
||||||
|
|
||||||
|
`OwncastSentry` exposes Maubot command decorators in `__init__.py`, but command
|
||||||
|
behavior lives in `CommandHandler`. The plugin entry points are thin wrappers
|
||||||
|
that catch unexpected exceptions and record command error metrics.
|
||||||
|
|
||||||
|
`CommandHandler.subscribe()` and `CommandHandler.unsubscribe()` translate
|
||||||
|
domain errors into user-facing Matrix replies. `subscriptions()` and `live()`
|
||||||
|
read resolved room subscriptions and render Markdown output with escaped
|
||||||
|
untrusted text.
|
||||||
|
|
||||||
|
Command listings are room-scoped and ordered by domain through repository
|
||||||
|
queries. `subscriptions()` includes online, offline, and unknown streams.
|
||||||
|
`live()` includes only rows marked online with a failure count at or below the
|
||||||
|
unknown threshold.
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
The database schema is managed by `repository.py` through Maubot's upgrade
|
||||||
|
table.
|
||||||
|
|
||||||
|
- Revision 1 creates `streams` and `subscriptions`.
|
||||||
|
- Revision 2 fixes `subscriptions.stream_domain` from `INTEGER` to `TEXT`.
|
||||||
|
- Revision 3 adds `streams.failure_counter`.
|
||||||
|
- Revision 4 replaces separate connect/disconnect timestamps with
|
||||||
|
`streams.online` and `streams.status_since`.
|
||||||
|
|
||||||
|
Repositories own SQL access. `StreamRepository.update()` writes display
|
||||||
|
and state fields; failure counters use dedicated methods.
|
||||||
|
`SubscriptionRepository` raises domain-specific errors for duplicate adds and
|
||||||
|
missing removes.
|
||||||
|
|
||||||
|
Resolved subscription listings join `subscriptions` to `streams`, which means
|
||||||
|
orphaned subscription rows without a stream row are skipped in room display
|
||||||
|
queries.
|
||||||
|
|
||||||
|
## Metrics
|
||||||
|
|
||||||
|
`MetricsService` uses a private `CollectorRegistry` so plugin metrics do not mix
|
||||||
|
with process-global Prometheus collectors.
|
||||||
|
|
||||||
|
The registered metrics cover:
|
||||||
|
|
||||||
|
- notification delivery attempts by type and result;
|
||||||
|
- current stream status per domain (`1` online, `0` offline, `-1` unknown);
|
||||||
|
- subscription count per domain;
|
||||||
|
- consecutive check failures per domain;
|
||||||
|
- last successful Owncast API response duration per domain;
|
||||||
|
- build/version information;
|
||||||
|
- open aiohttp connection count;
|
||||||
|
- internal scheduler and command errors.
|
||||||
|
|
||||||
|
Known counter label combinations are initialized to zero. This keeps the
|
||||||
|
Prometheus output stable before any events have occurred.
|
||||||
|
|
||||||
|
## Test Map
|
||||||
|
|
||||||
|
Start with these tests when changing package behavior:
|
||||||
|
|
||||||
|
- `tests/test_types.py`: immutable value objects, API response shape checks,
|
||||||
|
truncation, stream status derivation, and subscription errors.
|
||||||
|
- `tests/test_repository.py`: schema-backed stream and subscription repository
|
||||||
|
behavior.
|
||||||
|
- `tests/test_subscription_manager.py`: domain normalization, subscribe and
|
||||||
|
unsubscribe workflows, validation skipping, and room listing delegation.
|
||||||
|
- `tests/test_owncast_client.py`: Owncast API request handling, response body
|
||||||
|
limits, validation failures, User-Agent, response timing, and connection
|
||||||
|
counts.
|
||||||
|
- `tests/test_stream_monitor.py`: polling backoff, first-update suppression,
|
||||||
|
live/offline/title transitions, cleanup thresholds, exception isolation, and
|
||||||
|
monitor metrics.
|
||||||
|
- `tests/test_notification_service.py`: message formatting, sanitization,
|
||||||
|
cooldown behavior, broadcast failure accounting, cleanup notices, and
|
||||||
|
notification metrics.
|
||||||
|
- `tests/test_commands.py`: end-to-end Maubot command behavior, Markdown
|
||||||
|
escaping, duration formatting, room-scoped subscription listings, and live
|
||||||
|
listings.
|
||||||
|
- `tests/test_metrics.py`: Prometheus counters, gauges, response timer,
|
||||||
|
per-domain label removal, build info, open connections, and isolated registry
|
||||||
|
output.
|
||||||
+40
-25
@@ -14,6 +14,8 @@
|
|||||||
|
|
||||||
"""OwncastSentry maubot plugin."""
|
"""OwncastSentry maubot plugin."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from aiohttp import web
|
from aiohttp import web
|
||||||
@@ -23,12 +25,12 @@ from prometheus_client.exposition import choose_encoder
|
|||||||
|
|
||||||
from .commands import CommandHandler
|
from .commands import CommandHandler
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .database import StreamRepository, SubscriptionRepository
|
|
||||||
from .metrics import ErrorSource, MetricsService
|
from .metrics import ErrorSource, MetricsService
|
||||||
from .migrations import get_upgrade_table
|
|
||||||
from .notification_service import NotificationService
|
from .notification_service import NotificationService
|
||||||
from .owncast_client import OwncastClient
|
from .owncast_client import OwncastClient
|
||||||
|
from .repository import StreamRepository, SubscriptionRepository, get_upgrade_table
|
||||||
from .stream_monitor import StreamMonitor
|
from .stream_monitor import StreamMonitor
|
||||||
|
from .subscription_manager import SubscriptionManager
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from mautrix.util.async_db import Database, UpgradeTable
|
from mautrix.util.async_db import Database, UpgradeTable
|
||||||
@@ -49,10 +51,10 @@ class OwncastSentry(Plugin):
|
|||||||
return Config
|
return Config
|
||||||
|
|
||||||
async def start(self) -> None:
|
async def start(self) -> None:
|
||||||
"""Initialize all services and register recurring tasks.
|
"""Initialize services and schedule stream state updates.
|
||||||
|
|
||||||
Registers a recurring task every minute to update the state of
|
Starts a scheduler coroutine that updates subscribed streams at the top
|
||||||
all subscribed streams.
|
of each wall-clock minute.
|
||||||
"""
|
"""
|
||||||
# Load configuration
|
# Load configuration
|
||||||
config: Config = self.config # type: ignore[assignment]
|
config: Config = self.config # type: ignore[assignment]
|
||||||
@@ -97,30 +99,25 @@ class OwncastSentry(Plugin):
|
|||||||
metrics=self.metrics_service,
|
metrics=self.metrics_service,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize command handler
|
# Initialize subscription manager
|
||||||
self.command_handler = CommandHandler(
|
self.subscription_manager = SubscriptionManager(
|
||||||
self.owncast_client,
|
self.owncast_client,
|
||||||
self.stream_repo,
|
self.stream_repo,
|
||||||
self.subscription_repo,
|
self.subscription_repo,
|
||||||
self.log,
|
self.log,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Schedule periodic stream state updates every 60 seconds
|
# Initialize command handler
|
||||||
self.sched.run_periodically(60, self._update_all_stream_states)
|
self.command_handler = CommandHandler(
|
||||||
|
self.subscription_manager,
|
||||||
async def _update_all_stream_states(self) -> None:
|
|
||||||
"""Update all stream states."""
|
|
||||||
try:
|
|
||||||
# Get list of all stream domains with active subscriptions
|
|
||||||
subscribed_domains = (
|
|
||||||
await self.subscription_repo.get_all_subscribed_domains()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Delegate to stream monitor
|
# Schedule stream state updates at the top of each wall-clock minute.
|
||||||
await self.stream_monitor.update_all_streams(subscribed_domains)
|
self.sched.run_later(0, self._run_stream_updates_every_minute())
|
||||||
except Exception:
|
|
||||||
self.metrics_service.record_error(ErrorSource.SCHEDULER_LOOP)
|
async def stop(self) -> None:
|
||||||
self.log.exception("Unhandled exception in scheduler loop.")
|
"""Clean up resources by closing the HTTP session."""
|
||||||
|
await self.owncast_client.close()
|
||||||
|
|
||||||
@command.new(help="Subscribes to a new Owncast stream.")
|
@command.new(help="Subscribes to a new Owncast stream.")
|
||||||
@command.argument("url")
|
@command.argument("url")
|
||||||
@@ -164,6 +161,28 @@ class OwncastSentry(Plugin):
|
|||||||
self.log.exception("Unhandled exception in live command.")
|
self.log.exception("Unhandled exception in live command.")
|
||||||
await evt.reply("An unexpected error occurred. Please try again later.")
|
await evt.reply("An unexpected error occurred. Please try again later.")
|
||||||
|
|
||||||
|
async def _run_stream_updates_every_minute(self) -> None:
|
||||||
|
"""Run stream state updates at the top of each wall-clock minute."""
|
||||||
|
while True:
|
||||||
|
current_time = datetime.now(UTC)
|
||||||
|
delay = 60 - current_time.second - (current_time.microsecond / 1_000_000)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
await self._update_all_stream_states()
|
||||||
|
|
||||||
|
async def _update_all_stream_states(self) -> None:
|
||||||
|
"""Update stream states for domains with active subscriptions."""
|
||||||
|
try:
|
||||||
|
# Get list of all stream domains with active subscriptions
|
||||||
|
subscribed_domains = (
|
||||||
|
await self.subscription_repo.get_all_subscribed_domains()
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delegate to stream monitor
|
||||||
|
await self.stream_monitor.update_all_streams(subscribed_domains)
|
||||||
|
except Exception:
|
||||||
|
self.metrics_service.record_error(ErrorSource.SCHEDULER_LOOP)
|
||||||
|
self.log.exception("Unhandled exception in scheduler loop.")
|
||||||
|
|
||||||
async def _metrics_endpoint(self, request: web.Request) -> web.Response:
|
async def _metrics_endpoint(self, request: web.Request) -> web.Response:
|
||||||
"""Serve Prometheus metrics."""
|
"""Serve Prometheus metrics."""
|
||||||
accept = request.headers.get("Accept", "")
|
accept = request.headers.get("Accept", "")
|
||||||
@@ -172,7 +191,3 @@ class OwncastSentry(Plugin):
|
|||||||
response = web.Response(body=output)
|
response = web.Response(body=output)
|
||||||
response.headers["Content-Type"] = content_type
|
response.headers["Content-Type"] = content_type
|
||||||
return response
|
return response
|
||||||
|
|
||||||
async def stop(self) -> None:
|
|
||||||
"""Clean up resources by closing the HTTP session."""
|
|
||||||
await self.owncast_client.close()
|
|
||||||
|
|||||||
+112
-132
@@ -14,20 +14,75 @@
|
|||||||
|
|
||||||
"""Command handlers for OwncastSentry bot commands."""
|
"""Command handlers for OwncastSentry bot commands."""
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from .models import StreamStatus
|
from .types import (
|
||||||
from .utils import domainify, sanitize_for_markdown
|
AlreadySubscribedError,
|
||||||
|
InvalidOwncastInstanceError,
|
||||||
|
NotSubscribedError,
|
||||||
|
StreamStatus,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import logging
|
|
||||||
|
|
||||||
from maubot import MessageEvent # type: ignore[attr-defined]
|
from maubot import MessageEvent # type: ignore[attr-defined]
|
||||||
|
|
||||||
from .database import StreamRepository, SubscriptionRepository
|
from .subscription_manager import SubscriptionManager
|
||||||
from .owncast_client import OwncastClient
|
|
||||||
|
|
||||||
|
_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:
|
||||||
|
"""Sanitize text before Markdown escaping."""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
sanitized = text.replace("\n", " ").replace("\r", " ")
|
||||||
|
return " ".join(sanitized.split())
|
||||||
|
|
||||||
|
|
||||||
|
def _escape_markdown(text: str) -> str:
|
||||||
|
"""Escape Markdown special characters in untrusted text."""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
return text.translate(_MARKDOWN_ESCAPE_TABLE)
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_for_markdown(text: str) -> str:
|
||||||
|
"""Sanitize text for safe Markdown rendering."""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
return _escape_markdown(_sanitize_for_plain_text(text))
|
||||||
|
|
||||||
|
|
||||||
|
def _format_duration(timestamp_str: str, now: datetime) -> str:
|
||||||
|
"""Calculate and format the duration from a timestamp to now."""
|
||||||
|
try:
|
||||||
|
timestamp = datetime.fromisoformat(timestamp_str)
|
||||||
|
delta = now - timestamp
|
||||||
|
|
||||||
|
seconds = int(delta.total_seconds())
|
||||||
|
if seconds < 0:
|
||||||
|
return "unknown duration"
|
||||||
|
if seconds < _SECONDS_PER_MINUTE:
|
||||||
|
return f"{seconds} second{'s' if seconds != 1 else ''}"
|
||||||
|
if seconds < _SECONDS_PER_HOUR:
|
||||||
|
minutes = seconds // _SECONDS_PER_MINUTE
|
||||||
|
return f"{minutes} minute{'s' if minutes != 1 else ''}"
|
||||||
|
if seconds < _SECONDS_PER_DAY:
|
||||||
|
hours = seconds // _SECONDS_PER_HOUR
|
||||||
|
return f"{hours} hour{'s' if hours != 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:
|
class CommandHandler:
|
||||||
@@ -35,40 +90,23 @@ class CommandHandler:
|
|||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
owncast_client: OwncastClient,
|
subscription_manager: SubscriptionManager,
|
||||||
stream_repo: StreamRepository,
|
|
||||||
subscription_repo: SubscriptionRepository,
|
|
||||||
logger: logging.Logger,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the command handler.
|
"""Initialize the command handler.
|
||||||
|
|
||||||
:param owncast_client: Client for making API calls to Owncast instances.
|
:param subscription_manager: Subscription domain workflow coordinator.
|
||||||
:param stream_repo: Repository for stream data.
|
|
||||||
:param subscription_repo: Repository for subscription data.
|
|
||||||
:param logger: Logger instance for debugging.
|
|
||||||
"""
|
"""
|
||||||
self.owncast_client = owncast_client
|
self.subscription_manager = subscription_manager
|
||||||
self.stream_repo = stream_repo
|
|
||||||
self.subscription_repo = subscription_repo
|
|
||||||
self.log = logger
|
|
||||||
|
|
||||||
async def subscribe(self, evt: MessageEvent, url: str) -> None:
|
async def subscribe(self, evt: MessageEvent, url: str) -> None:
|
||||||
"""Subscribe a room to a stream's notifications.
|
"""Subscribe a room to a stream's notifications.
|
||||||
|
|
||||||
:param evt: MessageEvent of the message calling the command.
|
:param evt: MessageEvent of the message calling the command.
|
||||||
:param url: User supplied URL to a stream to subscribe to.
|
:param url: User-supplied Owncast URL, domain, or Fediverse-style address.
|
||||||
"""
|
"""
|
||||||
# Convert the user input to only a domain
|
try:
|
||||||
stream_domain = domainify(url)
|
stream_domain = await self.subscription_manager.subscribe(evt.room_id, url)
|
||||||
|
except InvalidOwncastInstanceError:
|
||||||
# How many subscriptions already exist for this domain?
|
|
||||||
subscription_count = await self.subscription_repo.count_by_domain(stream_domain)
|
|
||||||
|
|
||||||
if subscription_count == 0:
|
|
||||||
# No subscriptions; validate this is an Owncast stream.
|
|
||||||
is_valid = await self.owncast_client.validate_instance(stream_domain)
|
|
||||||
if not is_valid:
|
|
||||||
# Fetch returned nothing. Probably not Owncast.
|
|
||||||
await evt.reply(
|
await evt.reply(
|
||||||
"The URL you supplied does not appear to "
|
"The URL you supplied does not appear to "
|
||||||
"be a valid Owncast instance. You may have "
|
"be a valid Owncast instance. You may have "
|
||||||
@@ -76,28 +114,12 @@ class CommandHandler:
|
|||||||
"instance is offline."
|
"instance is offline."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
except AlreadySubscribedError as e:
|
||||||
# Try to add a new subscription for this stream in this room
|
|
||||||
try:
|
|
||||||
await self.subscription_repo.add(stream_domain, evt.room_id)
|
|
||||||
except sqlite3.IntegrityError:
|
|
||||||
# Room is already subscribed.
|
|
||||||
await evt.reply(
|
await evt.reply(
|
||||||
f"This room is already subscribed to notifications for {stream_domain}."
|
f"This room is already subscribed to notifications for {e.domain}."
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Try to add a placeholder row for the stream's state.
|
|
||||||
try:
|
|
||||||
await self.stream_repo.create(stream_domain)
|
|
||||||
# First time seeing this stream. Log it.
|
|
||||||
self.log.info(f"[{stream_domain}] Discovered new stream!")
|
|
||||||
except sqlite3.IntegrityError:
|
|
||||||
# Adding rows for known streams is expected.
|
|
||||||
pass
|
|
||||||
|
|
||||||
# All went well! Tell the user.
|
|
||||||
self.log.info(f"[{stream_domain}] Subscription added for room {evt.room_id}.")
|
|
||||||
await evt.reply(
|
await evt.reply(
|
||||||
f"Subscription added! This room will receive "
|
f"Subscription added! This room will receive "
|
||||||
f"notifications when {stream_domain} goes live."
|
f"notifications when {stream_domain} goes live."
|
||||||
@@ -107,68 +129,33 @@ class CommandHandler:
|
|||||||
"""Unsubscribe a room from a stream's notifications.
|
"""Unsubscribe a room from a stream's notifications.
|
||||||
|
|
||||||
:param evt: MessageEvent of the message calling the command.
|
:param evt: MessageEvent of the message calling the command.
|
||||||
:param url: User supplied URL to a stream to unsubscribe from.
|
:param url: User-supplied Owncast URL, domain, or Fediverse-style address.
|
||||||
"""
|
"""
|
||||||
# Convert the user input to only a domain
|
try:
|
||||||
stream_domain = domainify(url)
|
stream_domain = await self.subscription_manager.unsubscribe(
|
||||||
|
evt.room_id, url
|
||||||
# Attempt to delete the requested subscription
|
|
||||||
result = await self.subscription_repo.remove(stream_domain, evt.room_id)
|
|
||||||
|
|
||||||
# Did it work?
|
|
||||||
if result == 1:
|
|
||||||
# Yes, one row was deleted. Tell the user.
|
|
||||||
self.log.info(
|
|
||||||
f"[{stream_domain}] Subscription removed for room {evt.room_id}."
|
|
||||||
)
|
)
|
||||||
|
except NotSubscribedError as e:
|
||||||
|
await evt.reply(
|
||||||
|
f"This room is already not subscribed to notifications for {e.domain}."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
await evt.reply(
|
await evt.reply(
|
||||||
f"Subscription removed! This room will no "
|
f"Subscription removed! This room will no "
|
||||||
f"longer receive notifications for {stream_domain}."
|
f"longer receive notifications for {stream_domain}."
|
||||||
)
|
)
|
||||||
else:
|
|
||||||
# No, nothing changed. Tell the user.
|
|
||||||
await evt.reply(
|
|
||||||
"This room is already not subscribed to "
|
|
||||||
f"notifications for {stream_domain}."
|
|
||||||
)
|
|
||||||
|
|
||||||
def _format_duration(self, timestamp_str: str) -> str:
|
|
||||||
"""Calculate and format the duration from a timestamp to now.
|
|
||||||
|
|
||||||
:param timestamp_str: ISO 8601 timestamp string.
|
|
||||||
:return: Formatted duration string (e.g., "1 hour", "2 days").
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
timestamp = datetime.fromisoformat(timestamp_str)
|
|
||||||
now = datetime.now(UTC)
|
|
||||||
delta = now - timestamp
|
|
||||||
|
|
||||||
seconds = int(delta.total_seconds())
|
|
||||||
if seconds < 60:
|
|
||||||
return f"{seconds} second{'s' if seconds != 1 else ''}"
|
|
||||||
if seconds < 3600:
|
|
||||||
minutes = seconds // 60
|
|
||||||
return f"{minutes} minute{'s' if minutes != 1 else ''}"
|
|
||||||
if seconds < 86400:
|
|
||||||
hours = seconds // 3600
|
|
||||||
return f"{hours} hour{'s' if hours != 1 else ''}"
|
|
||||||
days = seconds // 86400
|
|
||||||
return f"{days} day{'s' if days != 1 else ''}"
|
|
||||||
except ValueError:
|
|
||||||
return "unknown duration"
|
|
||||||
|
|
||||||
async def subscriptions(self, evt: MessageEvent) -> None:
|
async def subscriptions(self, evt: MessageEvent) -> None:
|
||||||
"""List all stream subscriptions in the current room.
|
"""List all stream subscriptions in the current room.
|
||||||
|
|
||||||
:param evt: MessageEvent of the message calling the command.
|
:param evt: MessageEvent of the message calling the command.
|
||||||
"""
|
"""
|
||||||
# Get all stream domains this room is subscribed to
|
subscriptions = await self.subscription_manager.list_room_subscriptions(
|
||||||
subscribed_domains = (
|
evt.room_id
|
||||||
await self.subscription_repo.get_subscribed_streams_for_room(evt.room_id)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if there are no subscriptions
|
if not subscriptions:
|
||||||
if not subscribed_domains:
|
|
||||||
await evt.reply(
|
await evt.reply(
|
||||||
"This room is not subscribed to any Owncast "
|
"This room is not subscribed to any Owncast "
|
||||||
"instances.\n\nTo subscribe to an Owncast "
|
"instances.\n\nTo subscribe to an Owncast "
|
||||||
@@ -178,36 +165,35 @@ class CommandHandler:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Build the response message body as Markdown
|
# Build the response message body as Markdown
|
||||||
count = len(subscribed_domains)
|
count = len(subscriptions)
|
||||||
parts = [f"**Subscriptions for this room ({count}):**\n\n"]
|
parts = [f"**Subscriptions for this room ({count}):**\n\n"]
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
for domain in subscribed_domains:
|
for subscription in subscriptions:
|
||||||
# Get the stream state from the database
|
domain = subscription.domain
|
||||||
stream_state = await self.stream_repo.get_by_domain(domain)
|
stream_state = subscription.stream_state
|
||||||
if stream_state is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Determine stream name (use domain as fallback)
|
|
||||||
stream_name = stream_state.name or domain
|
stream_name = stream_state.name or domain
|
||||||
safe_stream_name = sanitize_for_markdown(stream_name)
|
safe_stream_name = _sanitize_for_markdown(stream_name)
|
||||||
|
|
||||||
# Start building this stream's entry with stream name as main bullet
|
# Start building this stream's entry with stream name as main bullet
|
||||||
parts.append(f"- **{safe_stream_name}** \n")
|
parts.append(f"- **{safe_stream_name}** \n")
|
||||||
|
|
||||||
# Add title if stream is online (as a sub-bullet)
|
# Add title if stream is online (as a sub-bullet)
|
||||||
if stream_state.status == StreamStatus.ONLINE and stream_state.title:
|
if stream_state.status == StreamStatus.ONLINE and stream_state.title:
|
||||||
safe_title = sanitize_for_markdown(stream_state.title)
|
safe_title = _sanitize_for_markdown(stream_state.title)
|
||||||
parts.append(f" - Title: {safe_title} \n")
|
parts.append(f" - Title: {safe_title} \n")
|
||||||
|
|
||||||
# Determine status and duration (as a sub-bullet)
|
# Determine status and duration (as a sub-bullet)
|
||||||
match stream_state.status:
|
match stream_state.status:
|
||||||
case StreamStatus.ONLINE if stream_state.last_connect_time:
|
case StreamStatus.ONLINE if stream_state.status_since:
|
||||||
duration = self._format_duration(stream_state.last_connect_time)
|
duration = _format_duration(stream_state.status_since, now)
|
||||||
parts.append(f" - Status: Online for {duration} \n")
|
parts.append(f" - Status: Online for {duration} \n")
|
||||||
|
case StreamStatus.ONLINE:
|
||||||
|
parts.append(" - Status: Online \n")
|
||||||
case StreamStatus.UNKNOWN:
|
case StreamStatus.UNKNOWN:
|
||||||
parts.append(" - Status: Unknown (instance unreachable) \n")
|
parts.append(" - Status: Unknown (instance unreachable) \n")
|
||||||
case StreamStatus.OFFLINE if stream_state.last_disconnect_time:
|
case StreamStatus.OFFLINE if stream_state.status_since:
|
||||||
duration = self._format_duration(stream_state.last_disconnect_time)
|
duration = _format_duration(stream_state.status_since, now)
|
||||||
parts.append(f" - Status: Offline for {duration} \n")
|
parts.append(f" - Status: Offline for {duration} \n")
|
||||||
case StreamStatus.OFFLINE:
|
case StreamStatus.OFFLINE:
|
||||||
parts.append(" - Status: Offline \n")
|
parts.append(" - Status: Offline \n")
|
||||||
@@ -229,13 +215,12 @@ class CommandHandler:
|
|||||||
|
|
||||||
:param evt: MessageEvent of the message calling the command.
|
:param evt: MessageEvent of the message calling the command.
|
||||||
"""
|
"""
|
||||||
# Get all stream domains this room is subscribed to
|
live_streams = await self.subscription_manager.list_live_room_subscriptions(
|
||||||
subscribed_domains = (
|
evt.room_id
|
||||||
await self.subscription_repo.get_subscribed_streams_for_room(evt.room_id)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if there are no subscriptions
|
if not live_streams:
|
||||||
if not subscribed_domains:
|
if not await self.subscription_manager.has_room_subscriptions(evt.room_id):
|
||||||
await evt.reply(
|
await evt.reply(
|
||||||
"This room is not subscribed to any Owncast "
|
"This room is not subscribed to any Owncast "
|
||||||
"instances.\n\nTo subscribe to an Owncast "
|
"instances.\n\nTo subscribe to an Owncast "
|
||||||
@@ -244,15 +229,6 @@ class CommandHandler:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Filter for only live streams (exclude unknown status)
|
|
||||||
live_streams = []
|
|
||||||
for domain in subscribed_domains:
|
|
||||||
stream_state = await self.stream_repo.get_by_domain(domain)
|
|
||||||
if stream_state and stream_state.status == StreamStatus.ONLINE:
|
|
||||||
live_streams.append((domain, stream_state))
|
|
||||||
|
|
||||||
# Check if there are no live streams
|
|
||||||
if not live_streams:
|
|
||||||
await evt.reply(
|
await evt.reply(
|
||||||
"No subscribed Owncast instances are currently "
|
"No subscribed Owncast instances are currently "
|
||||||
"live.\n\nUse `!subscriptions` to list all "
|
"live.\n\nUse `!subscriptions` to list all "
|
||||||
@@ -264,24 +240,28 @@ class CommandHandler:
|
|||||||
# Build the response message body as Markdown
|
# Build the response message body as Markdown
|
||||||
count = len(live_streams)
|
count = len(live_streams)
|
||||||
parts = [f"**Live Owncast instances ({count}):**\n\n"]
|
parts = [f"**Live Owncast instances ({count}):**\n\n"]
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
|
||||||
for domain, stream_state in live_streams:
|
for subscription in live_streams:
|
||||||
# Determine stream name (use domain as fallback)
|
domain = subscription.domain
|
||||||
|
stream_state = subscription.stream_state
|
||||||
stream_name = stream_state.name or domain
|
stream_name = stream_state.name or domain
|
||||||
safe_stream_name = sanitize_for_markdown(stream_name)
|
safe_stream_name = _sanitize_for_markdown(stream_name)
|
||||||
|
|
||||||
# Start building this stream's entry with stream name as main bullet
|
# Start building this stream's entry with stream name as main bullet
|
||||||
parts.append(f"- **{safe_stream_name}** \n")
|
parts.append(f"- **{safe_stream_name}** \n")
|
||||||
|
|
||||||
# Add title (should be present for live streams)
|
# Add title when available
|
||||||
if stream_state.title:
|
if stream_state.title:
|
||||||
safe_title = sanitize_for_markdown(stream_state.title)
|
safe_title = _sanitize_for_markdown(stream_state.title)
|
||||||
parts.append(f" - Title: {safe_title} \n")
|
parts.append(f" - Title: {safe_title} \n")
|
||||||
|
|
||||||
# Add status with duration
|
# Add status with duration
|
||||||
if stream_state.last_connect_time:
|
if stream_state.status_since:
|
||||||
duration = self._format_duration(stream_state.last_connect_time)
|
duration = _format_duration(stream_state.status_since, now)
|
||||||
parts.append(f" - Online for {duration} \n")
|
parts.append(f" - Online for {duration} \n")
|
||||||
|
else:
|
||||||
|
parts.append(" - Online \n")
|
||||||
|
|
||||||
# Add stream link
|
# Add stream link
|
||||||
parts.append(f" - Link: https://{domain}\n\n")
|
parts.append(f" - Link: https://{domain}\n\n")
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
# Copyright 2026 Logan Fick
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
|
|
||||||
"""Database repository classes for OwncastSentry."""
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from .models import StreamState
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from mautrix.util.async_db import Database
|
|
||||||
|
|
||||||
|
|
||||||
class StreamRepository:
|
|
||||||
"""Repository for managing stream data in the database."""
|
|
||||||
|
|
||||||
def __init__(self, database: Database):
|
|
||||||
"""Initialize the stream repository.
|
|
||||||
|
|
||||||
:param database: The maubot database instance.
|
|
||||||
"""
|
|
||||||
self.db = database
|
|
||||||
|
|
||||||
async def get_by_domain(self, domain: str) -> StreamState | None:
|
|
||||||
"""Get a stream's state by domain.
|
|
||||||
|
|
||||||
:param domain: The stream domain.
|
|
||||||
:return: StreamState if found, None otherwise.
|
|
||||||
"""
|
|
||||||
query = "SELECT * FROM streams WHERE domain=$1"
|
|
||||||
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
|
||||||
row = await conn.fetchrow(query, domain)
|
|
||||||
return StreamState.from_db_row(row) if row else None
|
|
||||||
|
|
||||||
async def create(self, domain: str) -> None:
|
|
||||||
"""Create a new stream entry in the database.
|
|
||||||
|
|
||||||
:param domain: The stream domain.
|
|
||||||
"""
|
|
||||||
query = "INSERT INTO streams (domain) VALUES ($1)"
|
|
||||||
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
|
||||||
await conn.execute(query, domain)
|
|
||||||
|
|
||||||
async def update(self, state: StreamState) -> None:
|
|
||||||
"""Update a stream's state in the database.
|
|
||||||
|
|
||||||
:param state: The StreamState to save.
|
|
||||||
"""
|
|
||||||
query = """UPDATE streams
|
|
||||||
SET name=$1, title=$2, last_connect_time=$3, last_disconnect_time=$4
|
|
||||||
WHERE domain=$5"""
|
|
||||||
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
|
||||||
await conn.execute(
|
|
||||||
query,
|
|
||||||
state.name,
|
|
||||||
state.title,
|
|
||||||
state.last_connect_time,
|
|
||||||
state.last_disconnect_time,
|
|
||||||
state.domain,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def exists(self, domain: str) -> bool:
|
|
||||||
"""Check if a stream exists in the database.
|
|
||||||
|
|
||||||
:param domain: The stream domain.
|
|
||||||
:return: True if exists, False otherwise.
|
|
||||||
"""
|
|
||||||
result = await self.get_by_domain(domain)
|
|
||||||
return result is not None
|
|
||||||
|
|
||||||
async def increment_failure_counter(self, domain: str) -> None:
|
|
||||||
"""Increment the failure counter for a stream by 1.
|
|
||||||
|
|
||||||
:param domain: The stream domain.
|
|
||||||
"""
|
|
||||||
query = """UPDATE streams
|
|
||||||
SET failure_counter = failure_counter + 1
|
|
||||||
WHERE domain=$1"""
|
|
||||||
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
|
||||||
await conn.execute(query, domain)
|
|
||||||
|
|
||||||
async def reset_failure_counter(self, domain: str) -> None:
|
|
||||||
"""Reset the failure counter for a stream to 0.
|
|
||||||
|
|
||||||
:param domain: The stream domain.
|
|
||||||
"""
|
|
||||||
query = """UPDATE streams
|
|
||||||
SET failure_counter = 0
|
|
||||||
WHERE domain=$1"""
|
|
||||||
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
|
||||||
await conn.execute(query, domain)
|
|
||||||
|
|
||||||
async def delete(self, domain: str) -> None:
|
|
||||||
"""Delete a stream record from the database.
|
|
||||||
|
|
||||||
:param domain: The stream domain.
|
|
||||||
"""
|
|
||||||
query = "DELETE FROM streams WHERE domain=$1"
|
|
||||||
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
|
||||||
await conn.execute(query, domain)
|
|
||||||
|
|
||||||
|
|
||||||
class SubscriptionRepository:
|
|
||||||
"""Repository for managing stream subscriptions in the database."""
|
|
||||||
|
|
||||||
def __init__(self, database: Database):
|
|
||||||
"""Initialize the subscription repository.
|
|
||||||
|
|
||||||
:param database: The maubot database instance.
|
|
||||||
"""
|
|
||||||
self.db = database
|
|
||||||
|
|
||||||
async def add(self, domain: str, room_id: str) -> None:
|
|
||||||
"""Add a subscription for a room to a stream.
|
|
||||||
|
|
||||||
:param domain: The stream domain.
|
|
||||||
:param room_id: The Matrix room ID.
|
|
||||||
:raises sqlite3.IntegrityError: If subscription already exists.
|
|
||||||
"""
|
|
||||||
query = "INSERT INTO subscriptions (stream_domain, room_id) VALUES ($1, $2)"
|
|
||||||
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
|
||||||
await conn.execute(query, domain, room_id)
|
|
||||||
|
|
||||||
async def remove(self, domain: str, room_id: str) -> int:
|
|
||||||
"""Remove a subscription for a room from a stream.
|
|
||||||
|
|
||||||
:param domain: The stream domain.
|
|
||||||
:param room_id: The Matrix room ID.
|
|
||||||
:return: Number of rows deleted (0 or 1).
|
|
||||||
"""
|
|
||||||
query = "DELETE FROM subscriptions WHERE stream_domain=$1 AND room_id=$2"
|
|
||||||
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
|
||||||
result = await conn.execute(query, domain, room_id)
|
|
||||||
return int(result.rowcount)
|
|
||||||
|
|
||||||
async def get_subscribed_rooms(self, domain: str) -> list[str]:
|
|
||||||
"""Get all room IDs subscribed to a stream.
|
|
||||||
|
|
||||||
:param domain: The stream domain.
|
|
||||||
:return: List of room IDs.
|
|
||||||
"""
|
|
||||||
query = "SELECT room_id FROM subscriptions WHERE stream_domain=$1"
|
|
||||||
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
|
||||||
results = await conn.fetch(query, domain)
|
|
||||||
return [row["room_id"] for row in results]
|
|
||||||
|
|
||||||
async def get_subscribed_streams_for_room(self, room_id: str) -> list[str]:
|
|
||||||
"""Get all stream domains that a room is subscribed to.
|
|
||||||
|
|
||||||
:param room_id: The Matrix room ID.
|
|
||||||
:return: List of stream domains.
|
|
||||||
"""
|
|
||||||
query = "SELECT stream_domain FROM subscriptions WHERE room_id=$1"
|
|
||||||
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
|
||||||
results = await conn.fetch(query, room_id)
|
|
||||||
return [row["stream_domain"] for row in results]
|
|
||||||
|
|
||||||
async def get_all_subscribed_domains(self) -> list[str]:
|
|
||||||
"""Get all unique stream domains that have at least one subscription.
|
|
||||||
|
|
||||||
:return: List of stream domains.
|
|
||||||
"""
|
|
||||||
query = "SELECT DISTINCT stream_domain FROM subscriptions"
|
|
||||||
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
|
||||||
results = await conn.fetch(query)
|
|
||||||
return [row["stream_domain"] for row in results]
|
|
||||||
|
|
||||||
async def count_by_domain(self, domain: str) -> int:
|
|
||||||
"""Count the number of subscriptions for a given stream domain.
|
|
||||||
|
|
||||||
:param domain: The stream domain.
|
|
||||||
:return: Number of subscriptions.
|
|
||||||
"""
|
|
||||||
query = "SELECT COUNT(*) FROM subscriptions WHERE stream_domain=$1"
|
|
||||||
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
|
||||||
result = await conn.fetchrow(query, domain)
|
|
||||||
return int(result[0])
|
|
||||||
|
|
||||||
async def delete_all_for_domain(self, domain: str) -> int:
|
|
||||||
"""Delete all subscriptions for a given stream domain.
|
|
||||||
|
|
||||||
:param domain: The stream domain.
|
|
||||||
:return: Number of subscriptions deleted.
|
|
||||||
"""
|
|
||||||
query = "DELETE FROM subscriptions WHERE stream_domain=$1"
|
|
||||||
async with self.db.acquire() as conn: # type: ignore[var-annotated]
|
|
||||||
result = await conn.execute(query, domain)
|
|
||||||
return int(result.rowcount)
|
|
||||||
+13
-11
@@ -21,7 +21,10 @@ from typing import TYPE_CHECKING
|
|||||||
|
|
||||||
from prometheus_client import CollectorRegistry, Counter, Gauge, Info
|
from prometheus_client import CollectorRegistry, Counter, Gauge, Info
|
||||||
|
|
||||||
from .models import StreamStatus
|
from .types import StreamStatus
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable, Generator
|
||||||
|
|
||||||
|
|
||||||
class NotificationType(StrEnum):
|
class NotificationType(StrEnum):
|
||||||
@@ -40,9 +43,6 @@ class ErrorSource(StrEnum):
|
|||||||
COMMAND = "command"
|
COMMAND = "command"
|
||||||
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable, Generator
|
|
||||||
|
|
||||||
# Mapping from StreamStatus enum to numeric gauge values
|
# Mapping from StreamStatus enum to numeric gauge values
|
||||||
_STATUS_VALUES: dict[StreamStatus, float] = {
|
_STATUS_VALUES: dict[StreamStatus, float] = {
|
||||||
StreamStatus.ONLINE: 1.0,
|
StreamStatus.ONLINE: 1.0,
|
||||||
@@ -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)
|
||||||
@@ -109,7 +110,7 @@ class MetricsService:
|
|||||||
)
|
)
|
||||||
self.api_response_seconds = Gauge(
|
self.api_response_seconds = Gauge(
|
||||||
"owncastsentry_api_response_seconds",
|
"owncastsentry_api_response_seconds",
|
||||||
"Last successful HTTP response time in seconds per stream domain",
|
"Last successful Owncast API query duration in seconds per stream domain",
|
||||||
["domain"],
|
["domain"],
|
||||||
registry=self.registry,
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
@@ -120,7 +121,7 @@ class MetricsService:
|
|||||||
)
|
)
|
||||||
self.open_connections = Gauge(
|
self.open_connections = Gauge(
|
||||||
"owncastsentry_http_connections_open",
|
"owncastsentry_http_connections_open",
|
||||||
"Total number of open HTTP connections (idle and active)",
|
"Current number of open HTTP connections (idle and active)",
|
||||||
registry=self.registry,
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
self.errors_total = Counter(
|
self.errors_total = Counter(
|
||||||
@@ -130,7 +131,7 @@ class MetricsService:
|
|||||||
registry=self.registry,
|
registry=self.registry,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Initialize all known label combinations so they start at 0
|
# Initialize known counter label combinations so they start at 0
|
||||||
for notification_type in NotificationType:
|
for notification_type in NotificationType:
|
||||||
for result in ("success", "failure"):
|
for result in ("success", "failure"):
|
||||||
self.notification_delivery_total.labels(
|
self.notification_delivery_total.labels(
|
||||||
@@ -185,10 +186,11 @@ class MetricsService:
|
|||||||
|
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def response_timer(self, domain: str) -> Generator[_ResponseTimer]:
|
def response_timer(self, domain: str) -> Generator[_ResponseTimer]:
|
||||||
"""Return a context manager that times an HTTP request.
|
"""Return a context manager that times an Owncast API query.
|
||||||
|
|
||||||
Call ``timer.success()`` inside the block to record the duration.
|
Call ``timer.success()`` inside the block to record the duration.
|
||||||
If ``success()`` is never called, nothing is recorded.
|
If ``success()`` is never called, any existing timing label for the
|
||||||
|
domain is removed.
|
||||||
|
|
||||||
:param domain: The stream domain being queried.
|
:param domain: The stream domain being queried.
|
||||||
"""
|
"""
|
||||||
@@ -196,7 +198,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.
|
||||||
|
|||||||
@@ -1,100 +0,0 @@
|
|||||||
# Copyright 2026 Logan Fick
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
|
|
||||||
"""Database migration definitions for OwncastSentry."""
|
|
||||||
|
|
||||||
from mautrix.util.async_db import Connection, UpgradeTable
|
|
||||||
|
|
||||||
upgrade_table = UpgradeTable()
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register(description="Initial revision") # type: ignore[arg-type, call-arg, untyped-decorator]
|
|
||||||
async def upgrade_v1(conn: Connection) -> None:
|
|
||||||
"""Create the initial database schema.
|
|
||||||
|
|
||||||
Creates the streams and subscriptions tables.
|
|
||||||
|
|
||||||
:param conn: A connection to run the v1 database migration on.
|
|
||||||
"""
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE "streams" (
|
|
||||||
"domain" TEXT NOT NULL UNIQUE,
|
|
||||||
"name" TEXT,
|
|
||||||
"title" TEXT,
|
|
||||||
"last_connect_time" TEXT,
|
|
||||||
"last_disconnect_time" TEXT,
|
|
||||||
PRIMARY KEY("domain")
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE "subscriptions" (
|
|
||||||
"stream_domain" INTEGER NOT NULL,
|
|
||||||
"room_id" TEXT NOT NULL,
|
|
||||||
UNIQUE("room_id","stream_domain")
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register( # type: ignore[arg-type, call-arg, untyped-decorator]
|
|
||||||
description="Fix stream_domain column type from INTEGER to TEXT"
|
|
||||||
)
|
|
||||||
async def upgrade_v2(conn: Connection) -> None:
|
|
||||||
"""Upgrade database schema to version 2 format.
|
|
||||||
|
|
||||||
Fixes the stream_domain column type in the subscriptions table
|
|
||||||
from INTEGER to TEXT.
|
|
||||||
|
|
||||||
:param conn: A connection to run the v2 database migration on.
|
|
||||||
"""
|
|
||||||
# Create new subscriptions table with correct schema
|
|
||||||
await conn.execute(
|
|
||||||
"""CREATE TABLE "subscriptions_new" (
|
|
||||||
"stream_domain" TEXT NOT NULL,
|
|
||||||
"room_id" TEXT NOT NULL,
|
|
||||||
UNIQUE("room_id","stream_domain")
|
|
||||||
)"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Copy all existing data from old table to new table
|
|
||||||
await conn.execute(
|
|
||||||
"""INSERT INTO subscriptions_new (stream_domain, room_id)
|
|
||||||
SELECT stream_domain, room_id FROM subscriptions"""
|
|
||||||
)
|
|
||||||
|
|
||||||
# Drop the old table and rename new table to original name
|
|
||||||
await conn.execute("DROP TABLE subscriptions")
|
|
||||||
await conn.execute("ALTER TABLE subscriptions_new RENAME TO subscriptions")
|
|
||||||
|
|
||||||
|
|
||||||
@upgrade_table.register( # type: ignore[arg-type, call-arg, untyped-decorator]
|
|
||||||
description="Add failure_counter column for backoff and auto-cleanup"
|
|
||||||
)
|
|
||||||
async def upgrade_v3(conn: Connection) -> None:
|
|
||||||
"""Upgrade database schema to version 3 format.
|
|
||||||
|
|
||||||
Adds the failure_counter column to track connection failures
|
|
||||||
for backoff and auto-cleanup.
|
|
||||||
|
|
||||||
:param conn: A connection to run the v3 database migration on.
|
|
||||||
"""
|
|
||||||
# Add failure_counter column with default value of 0
|
|
||||||
await conn.execute(
|
|
||||||
"""ALTER TABLE streams ADD COLUMN failure_counter INTEGER DEFAULT 0"""
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def get_upgrade_table() -> UpgradeTable:
|
|
||||||
"""Return the upgrade table with registered migrations."""
|
|
||||||
return upgrade_table
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
# Copyright 2026 Logan Fick
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
|
|
||||||
"""Data models for OwncastSentry."""
|
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
|
||||||
from enum import Enum
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from .utils import (
|
|
||||||
MAX_INSTANCE_TITLE_LENGTH,
|
|
||||||
MAX_STREAM_TITLE_LENGTH,
|
|
||||||
MAX_TAG_LENGTH,
|
|
||||||
UNKNOWN_STATUS_THRESHOLD,
|
|
||||||
truncate,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class StreamStatus(Enum):
|
|
||||||
"""Represents the status of a stream."""
|
|
||||||
|
|
||||||
ONLINE = "online"
|
|
||||||
OFFLINE = "offline"
|
|
||||||
UNKNOWN = "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class StreamState:
|
|
||||||
"""Represents the state of an Owncast stream."""
|
|
||||||
|
|
||||||
domain: str
|
|
||||||
name: str | None = None
|
|
||||||
title: str | None = None
|
|
||||||
last_connect_time: str | None = None
|
|
||||||
last_disconnect_time: str | None = None
|
|
||||||
failure_counter: int = 0
|
|
||||||
|
|
||||||
@property
|
|
||||||
def status(self) -> StreamStatus:
|
|
||||||
"""Derive stream status from failure count and connect times.
|
|
||||||
|
|
||||||
Returns UNKNOWN if failures exceed the threshold, ONLINE if a
|
|
||||||
connect time is present, or OFFLINE otherwise.
|
|
||||||
"""
|
|
||||||
if self.failure_counter > UNKNOWN_STATUS_THRESHOLD:
|
|
||||||
return StreamStatus.UNKNOWN
|
|
||||||
if self.last_connect_time is not None:
|
|
||||||
return StreamStatus.ONLINE
|
|
||||||
return StreamStatus.OFFLINE
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_api_response(cls, response: dict[str, Any], domain: str) -> StreamState:
|
|
||||||
"""Create a StreamState from an API response.
|
|
||||||
|
|
||||||
:param response: API response as a dictionary (camelCase keys).
|
|
||||||
:param domain: The stream domain.
|
|
||||||
:return: StreamState instance.
|
|
||||||
"""
|
|
||||||
return cls(
|
|
||||||
domain=domain,
|
|
||||||
title=truncate(response.get("streamTitle", ""), MAX_STREAM_TITLE_LENGTH),
|
|
||||||
last_connect_time=response.get("lastConnectTime"),
|
|
||||||
last_disconnect_time=response.get("lastDisconnectTime"),
|
|
||||||
)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_db_row(cls, row: dict[str, Any]) -> StreamState:
|
|
||||||
"""Create a StreamState from a database row.
|
|
||||||
|
|
||||||
:param row: Database row as a dictionary.
|
|
||||||
:return: StreamState instance.
|
|
||||||
"""
|
|
||||||
return cls(
|
|
||||||
domain=row["domain"],
|
|
||||||
name=row["name"],
|
|
||||||
title=row["title"],
|
|
||||||
last_connect_time=row["last_connect_time"],
|
|
||||||
last_disconnect_time=row["last_disconnect_time"],
|
|
||||||
failure_counter=row["failure_counter"],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class UpdateResult:
|
|
||||||
"""Result of a stream update cycle."""
|
|
||||||
|
|
||||||
total_streams: int
|
|
||||||
successful_checks: int
|
|
||||||
failed_checks: int
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class StreamConfig:
|
|
||||||
"""Represents the configuration of an Owncast stream."""
|
|
||||||
|
|
||||||
name: str = ""
|
|
||||||
tags: list[str] = field(default_factory=list)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_api_response(cls, response: dict[str, Any]) -> StreamConfig:
|
|
||||||
"""Create a StreamConfig from an API response.
|
|
||||||
|
|
||||||
:param response: API response as a dictionary.
|
|
||||||
:return: StreamConfig instance.
|
|
||||||
"""
|
|
||||||
# Truncate instance name to max length
|
|
||||||
name = truncate(response.get("name", ""), MAX_INSTANCE_TITLE_LENGTH)
|
|
||||||
|
|
||||||
# Truncate each tag to max length
|
|
||||||
raw_tags = response.get("tags", [])
|
|
||||||
tags = [truncate(tag, MAX_TAG_LENGTH) for tag in raw_tags]
|
|
||||||
|
|
||||||
return cls(name=name, tags=tags)
|
|
||||||
@@ -21,18 +21,27 @@ from typing import TYPE_CHECKING, Any
|
|||||||
from mautrix.types import MessageType, TextMessageEventContent
|
from mautrix.types import MessageType, TextMessageEventContent
|
||||||
|
|
||||||
from .metrics import NotificationType
|
from .metrics import NotificationType
|
||||||
from .utils import (
|
|
||||||
CLEANUP_DELETE_DAYS,
|
|
||||||
CLEANUP_WARNING_DAYS,
|
|
||||||
SECONDS_BETWEEN_NOTIFICATIONS,
|
|
||||||
sanitize_for_plain_text,
|
|
||||||
)
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import logging
|
import logging
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
from .database import SubscriptionRepository
|
|
||||||
from .metrics import MetricsService
|
from .metrics import MetricsService
|
||||||
|
from .repository import SubscriptionRepository
|
||||||
|
|
||||||
|
|
||||||
|
_SECONDS_BETWEEN_NOTIFICATIONS = 20 * 60
|
||||||
|
|
||||||
|
_CLEANUP_WARNING_DAYS = 83
|
||||||
|
_CLEANUP_DELETE_DAYS = 90
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_for_plain_text(text: str) -> str:
|
||||||
|
"""Sanitize text for plain text rendering."""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
return " ".join(text.split())
|
||||||
|
|
||||||
|
|
||||||
class NotificationService:
|
class NotificationService:
|
||||||
@@ -65,7 +74,7 @@ class NotificationService:
|
|||||||
domain: str,
|
domain: str,
|
||||||
name: str,
|
name: str,
|
||||||
title: str,
|
title: str,
|
||||||
tags: list[str],
|
tags: Sequence[str],
|
||||||
*,
|
*,
|
||||||
title_change: bool = False,
|
title_change: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -83,28 +92,34 @@ class NotificationService:
|
|||||||
time.monotonic() - self.notification_timers_cache[domain]
|
time.monotonic() - self.notification_timers_cache[domain]
|
||||||
)
|
)
|
||||||
self.log.info(
|
self.log.info(
|
||||||
f"[{domain}] Not sending notifications. Only "
|
"[%s] Not sending notifications. Only %s of required "
|
||||||
f"{seconds_since_last} of required "
|
"%s seconds have passed since last notification.",
|
||||||
f"{SECONDS_BETWEEN_NOTIFICATIONS} seconds have "
|
domain,
|
||||||
f"passed since last notification."
|
seconds_since_last,
|
||||||
|
_SECONDS_BETWEEN_NOTIFICATIONS,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
# Record that we're sending a notification now
|
|
||||||
self._record_notification(domain)
|
|
||||||
|
|
||||||
# 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)
|
||||||
|
|
||||||
|
# Record that a notification was sent if at least one room received it.
|
||||||
|
if successful > 0:
|
||||||
|
self._record_notification(domain)
|
||||||
|
|
||||||
# Log completion
|
# Log completion
|
||||||
notification_type = "title change" if title_change else "going live"
|
notification_type = "title change" if title_change else "going live"
|
||||||
self.log.info(
|
self.log.info(
|
||||||
f"[{domain}] Completed sending {notification_type} "
|
"[%s] Completed sending %s notifications! %s succeeded, %s failed.",
|
||||||
f"notifications! {successful} succeeded, "
|
domain,
|
||||||
f"{failed} failed."
|
notification_type,
|
||||||
|
successful,
|
||||||
|
failed,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.metrics.record_delivery(
|
self.metrics.record_delivery(
|
||||||
@@ -113,6 +128,76 @@ class NotificationService:
|
|||||||
failed=failed,
|
failed=failed,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def send_cleanup_warning(self, domain: str) -> None:
|
||||||
|
"""Send cleanup warning notification to all subscribed rooms.
|
||||||
|
|
||||||
|
:param domain: The stream domain.
|
||||||
|
"""
|
||||||
|
remaining_days = _CLEANUP_DELETE_DAYS - _CLEANUP_WARNING_DAYS
|
||||||
|
body_text = (
|
||||||
|
"⚠️ Warning: Subscription Cleanup Scheduled\n\n"
|
||||||
|
f"The Owncast instance at {domain} has been "
|
||||||
|
f"unreachable for {_CLEANUP_WARNING_DAYS} days. If it remains "
|
||||||
|
f"unreachable for {remaining_days} more days "
|
||||||
|
f"({_CLEANUP_DELETE_DAYS} days total), this subscription "
|
||||||
|
f"will be automatically removed."
|
||||||
|
)
|
||||||
|
|
||||||
|
successful, failed = await self._broadcast_to_rooms(domain, body_text)
|
||||||
|
self.log.info(
|
||||||
|
"[%s] Sent cleanup warning to %s rooms (%s failed).",
|
||||||
|
domain,
|
||||||
|
successful,
|
||||||
|
failed,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.metrics.record_delivery(
|
||||||
|
NotificationType.CLEANUP_WARNING, successful=successful, failed=failed
|
||||||
|
)
|
||||||
|
|
||||||
|
async def send_cleanup_deletion(self, domain: str) -> None:
|
||||||
|
"""Send cleanup deletion notification to all subscribed rooms.
|
||||||
|
|
||||||
|
:param domain: The stream domain.
|
||||||
|
"""
|
||||||
|
body_text = (
|
||||||
|
"🗑️ Subscription Automatically Removed\n\n"
|
||||||
|
f"The Owncast instance at {domain} has been "
|
||||||
|
f"unreachable for {_CLEANUP_DELETE_DAYS} days and has been "
|
||||||
|
f"automatically removed from subscriptions in this "
|
||||||
|
f"room.\n\n"
|
||||||
|
f"If the instance comes online again and you want to "
|
||||||
|
f"resubscribe, run `!subscribe {domain}`."
|
||||||
|
)
|
||||||
|
|
||||||
|
successful, failed = await self._broadcast_to_rooms(domain, body_text)
|
||||||
|
self.log.info(
|
||||||
|
"[%s] Sent cleanup deletion notice to %s rooms (%s failed).",
|
||||||
|
domain,
|
||||||
|
successful,
|
||||||
|
failed,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.metrics.record_delivery(
|
||||||
|
NotificationType.CLEANUP_DELETION, successful=successful, failed=failed
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_last_notification_time(self, domain: str) -> float:
|
||||||
|
"""Get the cached live/title-change notification timestamp for a domain.
|
||||||
|
|
||||||
|
:param domain: The stream domain.
|
||||||
|
:return: Monotonic timestamp of the last successful live/title-change
|
||||||
|
notification, or 0 if none has been recorded.
|
||||||
|
"""
|
||||||
|
return self.notification_timers_cache.get(domain, 0)
|
||||||
|
|
||||||
|
def clear_notification_state(self, domain: str) -> None:
|
||||||
|
"""Clear cached notification state for a deleted domain.
|
||||||
|
|
||||||
|
:param domain: The stream domain to remove from local caches.
|
||||||
|
"""
|
||||||
|
self.notification_timers_cache.pop(domain, None)
|
||||||
|
|
||||||
async def _send_notification(
|
async def _send_notification(
|
||||||
self, room_id: str, body_text: str, domain: str
|
self, room_id: str, body_text: str, domain: str
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -121,20 +206,28 @@ class NotificationService:
|
|||||||
:param room_id: The Matrix room ID to send to.
|
:param room_id: The Matrix room ID to send to.
|
||||||
:param body_text: The message body text.
|
:param body_text: The message body text.
|
||||||
:param domain: The stream domain (for logging).
|
:param domain: The stream domain (for logging).
|
||||||
:raises Exception: If sending fails.
|
Propagates send errors so broadcast delivery accounting can count failures.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
content = TextMessageEventContent(msgtype=MessageType.TEXT, body=body_text)
|
content = TextMessageEventContent(msgtype=MessageType.TEXT, body=body_text)
|
||||||
await self.client.send_message(room_id, content)
|
await self.client.send_message(room_id, content)
|
||||||
except Exception as exception:
|
except Exception as exception:
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
f"[{domain}] Failed to send notification "
|
"[%s] Failed to send notification message to room [%s]: %s",
|
||||||
f"message to room [{room_id}]: {exception}"
|
domain,
|
||||||
|
room_id,
|
||||||
|
exception,
|
||||||
)
|
)
|
||||||
raise
|
raise
|
||||||
|
|
||||||
def _format_message(
|
def _format_message(
|
||||||
self, name: str, title: str, domain: str, tags: list[str], title_change: bool
|
self,
|
||||||
|
name: str,
|
||||||
|
title: str,
|
||||||
|
domain: str,
|
||||||
|
tags: Sequence[str],
|
||||||
|
*,
|
||||||
|
title_change: bool,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Format the notification message body.
|
"""Format the notification message body.
|
||||||
|
|
||||||
@@ -147,7 +240,7 @@ class NotificationService:
|
|||||||
"""
|
"""
|
||||||
# Use name if available, fallback to domain
|
# Use name if available, fallback to domain
|
||||||
stream_name = name or domain
|
stream_name = name or domain
|
||||||
safe_stream_name = sanitize_for_plain_text(stream_name)
|
safe_stream_name = _sanitize_for_plain_text(stream_name)
|
||||||
|
|
||||||
# Choose message based on notification type
|
# Choose message based on notification type
|
||||||
if title_change:
|
if title_change:
|
||||||
@@ -157,7 +250,7 @@ class NotificationService:
|
|||||||
|
|
||||||
# Add title if present
|
# Add title if present
|
||||||
if title:
|
if title:
|
||||||
safe_title = sanitize_for_plain_text(title)
|
safe_title = _sanitize_for_plain_text(title)
|
||||||
parts.append(f"\nStream Title: {safe_title}")
|
parts.append(f"\nStream Title: {safe_title}")
|
||||||
|
|
||||||
# Add stream URL
|
# Add stream URL
|
||||||
@@ -165,39 +258,30 @@ class NotificationService:
|
|||||||
|
|
||||||
# Add tags if present
|
# Add tags if present
|
||||||
if tags:
|
if tags:
|
||||||
safe_tags = [
|
tag_text = " ".join(
|
||||||
safe_tag
|
f"#{safe_tag}"
|
||||||
for tag in tags
|
for tag in tags
|
||||||
if (safe_tag := sanitize_for_plain_text(tag))
|
if (safe_tag := _sanitize_for_plain_text(tag))
|
||||||
and not safe_tag.startswith(".")
|
and not safe_tag.startswith(".")
|
||||||
]
|
)
|
||||||
|
|
||||||
if safe_tags:
|
if tag_text:
|
||||||
parts.append(f"\n\n{' '.join(f'#{tag}' for tag in safe_tags)}")
|
parts.append(f"\n\n{tag_text}")
|
||||||
|
|
||||||
return "".join(parts)
|
return "".join(parts)
|
||||||
|
|
||||||
def get_last_notification_time(self, domain: str) -> float:
|
|
||||||
"""Get the timestamp of the last notification sent for a domain.
|
|
||||||
|
|
||||||
:param domain: The stream domain.
|
|
||||||
:return: Unix timestamp of last notification, or 0 if never notified.
|
|
||||||
"""
|
|
||||||
return self.notification_timers_cache.get(domain, 0)
|
|
||||||
|
|
||||||
def _can_notify(self, domain: str) -> bool:
|
def _can_notify(self, domain: str) -> bool:
|
||||||
"""Check if enough time has passed to send another notification.
|
"""Check if enough time has passed to send another notification.
|
||||||
|
|
||||||
:param domain: The stream domain.
|
:param domain: The stream domain.
|
||||||
:return: True if notification can be sent, False otherwise.
|
:return: True if notification can be sent, False otherwise.
|
||||||
"""
|
"""
|
||||||
if domain not in self.notification_timers_cache:
|
last_notification_time = self.notification_timers_cache.get(domain)
|
||||||
return True
|
return (
|
||||||
|
last_notification_time is None
|
||||||
seconds_since_last = round(
|
or time.monotonic() - last_notification_time
|
||||||
time.monotonic() - self.notification_timers_cache[domain]
|
>= _SECONDS_BETWEEN_NOTIFICATIONS
|
||||||
)
|
)
|
||||||
return seconds_since_last >= SECONDS_BETWEEN_NOTIFICATIONS
|
|
||||||
|
|
||||||
def _record_notification(self, domain: str) -> None:
|
def _record_notification(self, domain: str) -> None:
|
||||||
"""Record that a notification was sent at the current time.
|
"""Record that a notification was sent at the current time.
|
||||||
@@ -218,55 +302,6 @@ class NotificationService:
|
|||||||
self._send_notification(room_id, body_text, domain) for room_id in room_ids
|
self._send_notification(room_id, body_text, domain) for room_id in room_ids
|
||||||
]
|
]
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
||||||
failed = sum(1 for r in results if isinstance(r, Exception))
|
failed = sum(1 for r in results if isinstance(r, BaseException))
|
||||||
successful = len(results) - failed
|
successful = len(results) - failed
|
||||||
return successful, failed
|
return successful, failed
|
||||||
|
|
||||||
async def send_cleanup_warning(self, domain: str) -> None:
|
|
||||||
"""Send cleanup warning notification to all subscribed rooms.
|
|
||||||
|
|
||||||
:param domain: The stream domain.
|
|
||||||
"""
|
|
||||||
remaining_days = CLEANUP_DELETE_DAYS - CLEANUP_WARNING_DAYS
|
|
||||||
body_text = (
|
|
||||||
"⚠️ Warning: Subscription Cleanup Scheduled\n\n"
|
|
||||||
f"The Owncast instance at {domain} has been "
|
|
||||||
f"unreachable for {CLEANUP_WARNING_DAYS} days. If it remains "
|
|
||||||
f"unreachable for {remaining_days} more days "
|
|
||||||
f"({CLEANUP_DELETE_DAYS} days total), this subscription "
|
|
||||||
f"will be automatically removed."
|
|
||||||
)
|
|
||||||
|
|
||||||
successful, failed = await self._broadcast_to_rooms(domain, body_text)
|
|
||||||
self.log.info(
|
|
||||||
f"[{domain}] Sent cleanup warning to {successful} rooms ({failed} failed)."
|
|
||||||
)
|
|
||||||
|
|
||||||
self.metrics.record_delivery(
|
|
||||||
NotificationType.CLEANUP_WARNING, successful=successful, failed=failed
|
|
||||||
)
|
|
||||||
|
|
||||||
async def send_cleanup_deletion(self, domain: str) -> None:
|
|
||||||
"""Send cleanup deletion notification to all subscribed rooms.
|
|
||||||
|
|
||||||
:param domain: The stream domain.
|
|
||||||
"""
|
|
||||||
body_text = (
|
|
||||||
"🗑️ Subscription Automatically Removed\n\n"
|
|
||||||
f"The Owncast instance at {domain} has been "
|
|
||||||
f"unreachable for {CLEANUP_DELETE_DAYS} days and has been "
|
|
||||||
f"automatically removed from subscriptions in this "
|
|
||||||
f"room.\n\n"
|
|
||||||
f"If the instance comes online again and you want to "
|
|
||||||
f"resubscribe, run `!subscribe {domain}`."
|
|
||||||
)
|
|
||||||
|
|
||||||
successful, failed = await self._broadcast_to_rooms(domain, body_text)
|
|
||||||
self.log.info(
|
|
||||||
f"[{domain}] Sent cleanup deletion notice to "
|
|
||||||
f"{successful} rooms ({failed} failed)."
|
|
||||||
)
|
|
||||||
|
|
||||||
self.metrics.record_delivery(
|
|
||||||
NotificationType.CLEANUP_DELETION, successful=successful, failed=failed
|
|
||||||
)
|
|
||||||
|
|||||||
+147
-60
@@ -14,16 +14,16 @@
|
|||||||
|
|
||||||
"""HTTP client for querying Owncast instance APIs."""
|
"""HTTP client for querying Owncast instance APIs."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from http import HTTPStatus
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import aiohttp
|
import aiohttp
|
||||||
|
|
||||||
from .models import StreamConfig, StreamState
|
from .types import (
|
||||||
from .utils import (
|
InvalidApiResponseError,
|
||||||
OWNCAST_CONFIG_PATH,
|
StreamConfigObservation,
|
||||||
OWNCAST_STATUS_PATH,
|
StreamStateObservation,
|
||||||
REQUIRED_STATUS_FIELDS,
|
|
||||||
user_agent,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -32,6 +32,48 @@ if TYPE_CHECKING:
|
|||||||
from .metrics import MetricsService
|
from .metrics import MetricsService
|
||||||
|
|
||||||
|
|
||||||
|
_OWNCAST_STATUS_PATH = "/api/status"
|
||||||
|
_OWNCAST_CONFIG_PATH = "/api/config"
|
||||||
|
_MAX_JSON_RESPONSE_BYTES = 1024 * 1024
|
||||||
|
_JSON_READ_CHUNK_BYTES = 64 * 1024
|
||||||
|
_HTTP_CONNECTION_LIMIT = 1000
|
||||||
|
_HTTP_CONNECTION_LIMIT_PER_HOST = 1
|
||||||
|
_HTTP_KEEPALIVE_TIMEOUT_SECONDS = 120
|
||||||
|
_HTTP_CONNECT_TIMEOUT_SECONDS = 5
|
||||||
|
_HTTP_READ_TIMEOUT_SECONDS = 5
|
||||||
|
|
||||||
|
|
||||||
|
def _user_agent(version: str) -> str:
|
||||||
|
"""Build the User-Agent header string for HTTP requests."""
|
||||||
|
return (
|
||||||
|
f"OwncastSentry/{version}"
|
||||||
|
" (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _read_limited_response_body(
|
||||||
|
response: aiohttp.ClientResponse,
|
||||||
|
) -> bytearray | None:
|
||||||
|
"""Read a response body while enforcing the maximum JSON response size."""
|
||||||
|
# Check Content-Length first when the server provides it so clearly
|
||||||
|
# oversized responses can be rejected before buffering any body bytes.
|
||||||
|
if (
|
||||||
|
response.content_length is not None
|
||||||
|
and response.content_length > _MAX_JSON_RESPONSE_BYTES
|
||||||
|
):
|
||||||
|
return None
|
||||||
|
|
||||||
|
body = bytearray()
|
||||||
|
# Read until EOF instead of using one read(n) call. aiohttp's read(n)
|
||||||
|
# may return a partial body as soon as data is available.
|
||||||
|
async for chunk in response.content.iter_chunked(_JSON_READ_CHUNK_BYTES):
|
||||||
|
body.extend(chunk)
|
||||||
|
if len(body) > _MAX_JSON_RESPONSE_BYTES:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return body
|
||||||
|
|
||||||
|
|
||||||
class OwncastClient:
|
class OwncastClient:
|
||||||
"""HTTP client for communicating with Owncast instances."""
|
"""HTTP client for communicating with Owncast instances."""
|
||||||
|
|
||||||
@@ -51,15 +93,18 @@ class OwncastClient:
|
|||||||
self.metrics = metrics
|
self.metrics = metrics
|
||||||
|
|
||||||
# Set up HTTP session configuration
|
# Set up HTTP session configuration
|
||||||
headers = {"User-Agent": user_agent(version)}
|
headers = {"User-Agent": _user_agent(version)}
|
||||||
cookie_jar = aiohttp.DummyCookieJar()
|
cookie_jar = aiohttp.DummyCookieJar()
|
||||||
connector = aiohttp.TCPConnector(
|
connector = aiohttp.TCPConnector(
|
||||||
use_dns_cache=False,
|
use_dns_cache=False,
|
||||||
limit=1000,
|
limit=_HTTP_CONNECTION_LIMIT,
|
||||||
limit_per_host=1,
|
limit_per_host=_HTTP_CONNECTION_LIMIT_PER_HOST,
|
||||||
keepalive_timeout=120,
|
keepalive_timeout=_HTTP_KEEPALIVE_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
timeout = aiohttp.ClientTimeout(
|
||||||
|
sock_connect=_HTTP_CONNECT_TIMEOUT_SECONDS,
|
||||||
|
sock_read=_HTTP_READ_TIMEOUT_SECONDS,
|
||||||
)
|
)
|
||||||
timeout = aiohttp.ClientTimeout(sock_connect=5, sock_read=5)
|
|
||||||
|
|
||||||
self.session = aiohttp.ClientSession(
|
self.session = aiohttp.ClientSession(
|
||||||
headers=headers,
|
headers=headers,
|
||||||
@@ -68,83 +113,68 @@ class OwncastClient:
|
|||||||
connector=connector,
|
connector=connector,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def _fetch_json(self, domain: str, path: str) -> dict[str, Any] | None:
|
async def get_stream_state(self, domain: str) -> StreamStateObservation | None:
|
||||||
"""Fetch and parse JSON from an Owncast API endpoint.
|
|
||||||
|
|
||||||
:param domain: The domain to query.
|
|
||||||
:param path: The API path to request.
|
|
||||||
:return: Parsed JSON response, or None on error.
|
|
||||||
"""
|
|
||||||
url = f"https://{domain}{path}"
|
|
||||||
try:
|
|
||||||
async with self.session.get(url, allow_redirects=False) as response:
|
|
||||||
if response.status != 200:
|
|
||||||
self.log.warning(
|
|
||||||
f"[{domain}] Response to request on "
|
|
||||||
f"{path} was not 200, "
|
|
||||||
f"got {response.status} instead."
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
result: dict[str, Any] = await response.json()
|
|
||||||
return result
|
|
||||||
except (ValueError, aiohttp.ContentTypeError) as e:
|
|
||||||
self.log.warning(
|
|
||||||
f"[{domain}] Rejecting response to request on "
|
|
||||||
f"{path} as could not be "
|
|
||||||
f"interpreted as JSON: {e}"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
except (aiohttp.ClientError, TimeoutError, OSError) as e:
|
|
||||||
self.log.warning(f"[{domain}] Error making GET request to {path}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def get_stream_state(self, domain: str) -> StreamState | None:
|
|
||||||
"""Get the current stream state for a given domain.
|
"""Get the current stream state for a given domain.
|
||||||
|
|
||||||
HTTPS on port 443 is assumed, no other protocols or ports
|
HTTPS on port 443 is assumed, no other protocols or ports
|
||||||
are supported.
|
are supported.
|
||||||
|
|
||||||
:param domain: The domain (not URL) where the stream is hosted.
|
:param domain: The domain (not URL) where the stream is hosted.
|
||||||
:return: A StreamState if available, None on error.
|
:return: A StreamStateObservation if available, None on error.
|
||||||
"""
|
"""
|
||||||
self.log.debug(f"[{domain}] Fetching current stream state...")
|
self.log.debug("[%s] Fetching current stream state...", domain)
|
||||||
with self.metrics.response_timer(domain) as timer:
|
with self.metrics.response_timer(domain) as timer:
|
||||||
new_state = await self._fetch_json(domain, OWNCAST_STATUS_PATH)
|
new_state = await self._fetch_json(domain, _OWNCAST_STATUS_PATH)
|
||||||
|
|
||||||
if new_state is None:
|
if new_state is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Validate the response contains all basic info needed
|
try:
|
||||||
missing = REQUIRED_STATUS_FIELDS - new_state.keys()
|
stream_observation = StreamStateObservation.from_api_response(
|
||||||
if missing:
|
new_state, domain
|
||||||
|
)
|
||||||
|
except InvalidApiResponseError as e:
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
f"[{domain}] Rejecting response to request on "
|
"[%s] Rejecting response to request on %s as response "
|
||||||
f"{OWNCAST_STATUS_PATH} as it is missing "
|
"shape is invalid: %s",
|
||||||
f"fields: {', '.join(sorted(missing))}"
|
domain,
|
||||||
|
_OWNCAST_STATUS_PATH,
|
||||||
|
e,
|
||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
timer.success()
|
timer.success()
|
||||||
return StreamState.from_api_response(new_state, domain)
|
return stream_observation
|
||||||
|
|
||||||
async def get_stream_config(self, domain: str) -> StreamConfig | None:
|
async def get_stream_config(self, domain: str) -> StreamConfigObservation | None:
|
||||||
"""Get the current stream config for a given domain.
|
"""Get the current stream config for a given domain.
|
||||||
|
|
||||||
HTTPS on port 443 is assumed, no other protocols or ports
|
HTTPS on port 443 is assumed, no other protocols or ports
|
||||||
are supported.
|
are supported.
|
||||||
|
|
||||||
:param domain: The domain (not URL) where the stream is hosted.
|
:param domain: The domain (not URL) where the stream is hosted.
|
||||||
:return: A StreamConfig, or None if fetch failed.
|
:return: A StreamConfigObservation, or None if fetch failed.
|
||||||
"""
|
"""
|
||||||
self.log.debug(f"[{domain}] Fetching current stream config...")
|
self.log.debug("[%s] Fetching current stream config...", domain)
|
||||||
with self.metrics.response_timer(domain) as timer:
|
with self.metrics.response_timer(domain) as timer:
|
||||||
config = await self._fetch_json(domain, OWNCAST_CONFIG_PATH)
|
config = await self._fetch_json(domain, _OWNCAST_CONFIG_PATH)
|
||||||
if config is None:
|
if config is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
stream_config = StreamConfigObservation.from_api_response(config)
|
||||||
|
except InvalidApiResponseError as e:
|
||||||
|
self.log.warning(
|
||||||
|
"[%s] Rejecting response to request on %s as response "
|
||||||
|
"shape is invalid: %s",
|
||||||
|
domain,
|
||||||
|
_OWNCAST_CONFIG_PATH,
|
||||||
|
e,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
timer.success()
|
timer.success()
|
||||||
return StreamConfig.from_api_response(config)
|
return stream_config
|
||||||
|
|
||||||
async def validate_instance(self, domain: str) -> bool:
|
async def validate_instance(self, domain: str) -> bool:
|
||||||
"""Validate that a domain is a valid Owncast instance.
|
"""Validate that a domain is a valid Owncast instance.
|
||||||
@@ -161,10 +191,67 @@ 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:
|
||||||
"""Close the HTTP session."""
|
"""Close the HTTP session."""
|
||||||
await self.session.close()
|
await self.session.close()
|
||||||
|
|
||||||
|
async def _fetch_json(self, domain: str, path: str) -> dict[str, Any] | None:
|
||||||
|
"""Fetch and parse JSON from an Owncast API endpoint.
|
||||||
|
|
||||||
|
:param domain: The domain to query.
|
||||||
|
:param path: The API path to request.
|
||||||
|
:return: Parsed JSON response, or None on error.
|
||||||
|
"""
|
||||||
|
url = f"https://{domain}{path}"
|
||||||
|
try:
|
||||||
|
async with self.session.get(url, allow_redirects=False) as response:
|
||||||
|
if response.status != HTTPStatus.OK:
|
||||||
|
self.log.warning(
|
||||||
|
"[%s] Response to request on %s was not 200, got %s instead.",
|
||||||
|
domain,
|
||||||
|
path,
|
||||||
|
response.status,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
body = await _read_limited_response_body(response)
|
||||||
|
if body is None:
|
||||||
|
self.log.warning(
|
||||||
|
"[%s] Rejecting response to request on %s as it "
|
||||||
|
"was larger than %s bytes.",
|
||||||
|
domain,
|
||||||
|
path,
|
||||||
|
_MAX_JSON_RESPONSE_BYTES,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
result = json.loads(body)
|
||||||
|
if not isinstance(result, dict):
|
||||||
|
self.log.warning(
|
||||||
|
"[%s] Rejecting response to request on %s as JSON "
|
||||||
|
"was not an object.",
|
||||||
|
domain,
|
||||||
|
path,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
except ValueError as e:
|
||||||
|
self.log.warning(
|
||||||
|
"[%s] Rejecting response to request on %s as could not "
|
||||||
|
"be interpreted as JSON: %s",
|
||||||
|
domain,
|
||||||
|
path,
|
||||||
|
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
|
||||||
|
|||||||
@@ -0,0 +1,493 @@
|
|||||||
|
# Copyright 2026 Logan Fick
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Repository and schema upgrade definitions for OwncastSentry.
|
||||||
|
|
||||||
|
Only SQLite is supported as a database backend for now.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from mautrix.util.async_db import Connection, UpgradeTable
|
||||||
|
|
||||||
|
from .types import (
|
||||||
|
UNKNOWN_STATUS_THRESHOLD,
|
||||||
|
AlreadySubscribedError,
|
||||||
|
NotSubscribedError,
|
||||||
|
RoomSubscription,
|
||||||
|
StreamState,
|
||||||
|
format_status_since,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from mautrix.util.async_db import Database
|
||||||
|
|
||||||
|
|
||||||
|
upgrade_table = UpgradeTable()
|
||||||
|
|
||||||
|
|
||||||
|
class _Unset:
|
||||||
|
"""Sentinel type for omitted partial stream update fields."""
|
||||||
|
|
||||||
|
__slots__ = ()
|
||||||
|
|
||||||
|
|
||||||
|
_UNSET = _Unset()
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_legacy_status_since(value: Any) -> str | None:
|
||||||
|
"""Normalize a legacy timestamp value to the canonical UTC format."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
parsed = value
|
||||||
|
elif isinstance(value, str):
|
||||||
|
value = value.strip()
|
||||||
|
if not value:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = datetime.fromisoformat(value)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
return None
|
||||||
|
return format_status_since(parsed.astimezone(UTC))
|
||||||
|
|
||||||
|
|
||||||
|
@upgrade_table.register( # type: ignore[arg-type, call-arg, untyped-decorator]
|
||||||
|
description="Initial revision"
|
||||||
|
)
|
||||||
|
async def upgrade_v1(conn: Connection) -> None:
|
||||||
|
"""Create the initial database schema.
|
||||||
|
|
||||||
|
Creates the streams and subscriptions tables.
|
||||||
|
|
||||||
|
:param conn: A connection to run the v1 database migration on.
|
||||||
|
"""
|
||||||
|
await conn.execute(
|
||||||
|
"""CREATE TABLE "streams" (
|
||||||
|
"domain" TEXT NOT NULL UNIQUE,
|
||||||
|
"name" TEXT,
|
||||||
|
"title" TEXT,
|
||||||
|
"last_connect_time" TEXT,
|
||||||
|
"last_disconnect_time" TEXT,
|
||||||
|
PRIMARY KEY("domain")
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"""CREATE TABLE "subscriptions" (
|
||||||
|
"stream_domain" INTEGER NOT NULL,
|
||||||
|
"room_id" TEXT NOT NULL,
|
||||||
|
UNIQUE("room_id","stream_domain")
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@upgrade_table.register( # type: ignore[arg-type, call-arg, untyped-decorator]
|
||||||
|
description="Fix stream_domain column type from INTEGER to TEXT"
|
||||||
|
)
|
||||||
|
async def upgrade_v2(conn: Connection) -> None:
|
||||||
|
"""Upgrade database schema to version 2 format.
|
||||||
|
|
||||||
|
Fixes the stream_domain column type in the subscriptions table
|
||||||
|
from INTEGER to TEXT.
|
||||||
|
|
||||||
|
:param conn: A connection to run the v2 database migration on.
|
||||||
|
"""
|
||||||
|
await conn.execute(
|
||||||
|
"""CREATE TABLE "subscriptions_new" (
|
||||||
|
"stream_domain" TEXT NOT NULL,
|
||||||
|
"room_id" TEXT NOT NULL,
|
||||||
|
UNIQUE("room_id","stream_domain")
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
|
||||||
|
await conn.execute(
|
||||||
|
"""INSERT INTO subscriptions_new (stream_domain, room_id)
|
||||||
|
SELECT stream_domain, room_id FROM subscriptions"""
|
||||||
|
)
|
||||||
|
|
||||||
|
await conn.execute("DROP TABLE subscriptions")
|
||||||
|
await conn.execute("ALTER TABLE subscriptions_new RENAME TO subscriptions")
|
||||||
|
|
||||||
|
|
||||||
|
@upgrade_table.register( # type: ignore[arg-type, call-arg, untyped-decorator]
|
||||||
|
description="Add failure_counter column for backoff and auto-cleanup"
|
||||||
|
)
|
||||||
|
async def upgrade_v3(conn: Connection) -> None:
|
||||||
|
"""Upgrade database schema to version 3 format.
|
||||||
|
|
||||||
|
Adds the failure_counter column to track connection failures
|
||||||
|
for backoff and auto-cleanup.
|
||||||
|
|
||||||
|
:param conn: A connection to run the v3 database migration on.
|
||||||
|
"""
|
||||||
|
await conn.execute(
|
||||||
|
"""ALTER TABLE streams ADD COLUMN failure_counter INTEGER DEFAULT 0"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@upgrade_table.register( # type: ignore[arg-type, call-arg, untyped-decorator]
|
||||||
|
description="Store online status and current status timestamp"
|
||||||
|
)
|
||||||
|
async def upgrade_v4(conn: Connection) -> None:
|
||||||
|
"""Upgrade database schema to version 4 format.
|
||||||
|
|
||||||
|
Replaces separate connect and disconnect timestamp columns with an
|
||||||
|
authoritative online flag and a timestamp for the current status.
|
||||||
|
|
||||||
|
:param conn: A connection to run the v4 database migration on.
|
||||||
|
"""
|
||||||
|
await conn.execute(
|
||||||
|
"""CREATE TABLE "streams_new" (
|
||||||
|
"domain" TEXT NOT NULL UNIQUE,
|
||||||
|
"name" TEXT,
|
||||||
|
"title" TEXT,
|
||||||
|
"online" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"status_since" TEXT,
|
||||||
|
"failure_counter" INTEGER DEFAULT 0,
|
||||||
|
PRIMARY KEY("domain")
|
||||||
|
)"""
|
||||||
|
)
|
||||||
|
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""SELECT domain, name, title, last_connect_time, last_disconnect_time,
|
||||||
|
failure_counter
|
||||||
|
FROM streams"""
|
||||||
|
)
|
||||||
|
for row in rows:
|
||||||
|
last_connect_time = row["last_connect_time"]
|
||||||
|
online = last_connect_time is not None and str(last_connect_time).strip() != ""
|
||||||
|
legacy_timestamp = last_connect_time if online else row["last_disconnect_time"]
|
||||||
|
await conn.execute(
|
||||||
|
"""INSERT INTO streams_new (
|
||||||
|
domain, name, title, online, status_since, failure_counter
|
||||||
|
)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6)""",
|
||||||
|
row["domain"],
|
||||||
|
row["name"],
|
||||||
|
row["title"],
|
||||||
|
online,
|
||||||
|
_normalize_legacy_status_since(legacy_timestamp),
|
||||||
|
row["failure_counter"],
|
||||||
|
)
|
||||||
|
|
||||||
|
await conn.execute("DROP TABLE streams")
|
||||||
|
await conn.execute("ALTER TABLE streams_new RENAME TO streams")
|
||||||
|
|
||||||
|
|
||||||
|
def get_upgrade_table() -> UpgradeTable:
|
||||||
|
"""Return the repository upgrade table with registered migrations."""
|
||||||
|
return upgrade_table
|
||||||
|
|
||||||
|
|
||||||
|
class StreamRepository:
|
||||||
|
"""Repository for managing stream data in the database."""
|
||||||
|
|
||||||
|
def __init__(self, database: Database) -> None:
|
||||||
|
"""Initialize the stream repository.
|
||||||
|
|
||||||
|
:param database: The maubot database instance.
|
||||||
|
"""
|
||||||
|
self.db: Any = database
|
||||||
|
|
||||||
|
async def create(self, domain: str) -> bool:
|
||||||
|
"""Create a new stream entry in the database.
|
||||||
|
|
||||||
|
:param domain: The stream domain.
|
||||||
|
:return: True if created, False if the stream already existed.
|
||||||
|
"""
|
||||||
|
query = """INSERT INTO streams (domain)
|
||||||
|
VALUES ($1)
|
||||||
|
ON CONFLICT (domain) DO NOTHING"""
|
||||||
|
async with self.db.acquire() as conn:
|
||||||
|
result = await conn.execute(query, domain)
|
||||||
|
return int(result.rowcount) > 0
|
||||||
|
|
||||||
|
async def get_by_domain(self, domain: str) -> StreamState | None:
|
||||||
|
"""Get a stream's state by domain.
|
||||||
|
|
||||||
|
:param domain: The stream domain.
|
||||||
|
:return: StreamState if found, None otherwise.
|
||||||
|
"""
|
||||||
|
query = "SELECT * FROM streams WHERE domain=$1"
|
||||||
|
async with self.db.acquire() as conn:
|
||||||
|
row = await conn.fetchrow(query, domain)
|
||||||
|
return StreamState.from_db_row(row) if row else None
|
||||||
|
|
||||||
|
async def exists(self, domain: str) -> bool:
|
||||||
|
"""Check if a stream exists in the database.
|
||||||
|
|
||||||
|
:param domain: The stream domain.
|
||||||
|
:return: True if exists, False otherwise.
|
||||||
|
"""
|
||||||
|
result = await self.get_by_domain(domain)
|
||||||
|
return result is not None
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self,
|
||||||
|
domain: str,
|
||||||
|
*,
|
||||||
|
name: str | None | _Unset = _UNSET,
|
||||||
|
title: str | None | _Unset = _UNSET,
|
||||||
|
online: bool | _Unset = _UNSET,
|
||||||
|
status_since: str | None | _Unset = _UNSET,
|
||||||
|
) -> None:
|
||||||
|
"""Update only the supplied stream fields in the database.
|
||||||
|
|
||||||
|
Passing None writes NULL for nullable fields. Omitting a field leaves
|
||||||
|
that column unchanged.
|
||||||
|
|
||||||
|
:param domain: The stream domain.
|
||||||
|
:param name: Optional stream display name update.
|
||||||
|
:param title: Optional stream title update.
|
||||||
|
:param online: Optional stream online state update.
|
||||||
|
:param status_since: Optional current status timestamp update.
|
||||||
|
"""
|
||||||
|
# _UNSET marks omitted fields so None can still be written as SQL NULL.
|
||||||
|
update_name = not isinstance(name, _Unset)
|
||||||
|
update_title = not isinstance(title, _Unset)
|
||||||
|
update_online = not isinstance(online, _Unset)
|
||||||
|
update_status_since = not isinstance(status_since, _Unset)
|
||||||
|
|
||||||
|
if not (update_name or update_title or update_online or update_status_since):
|
||||||
|
return
|
||||||
|
|
||||||
|
name_value = None if isinstance(name, _Unset) else name
|
||||||
|
title_value = None if isinstance(title, _Unset) else title
|
||||||
|
online_value = None if isinstance(online, _Unset) else online
|
||||||
|
status_since_value = None if isinstance(status_since, _Unset) else status_since
|
||||||
|
|
||||||
|
# SQLite's IS NOT gives null-safe comparisons for the supported backend.
|
||||||
|
query = """UPDATE streams
|
||||||
|
SET name = CASE WHEN $1 THEN $2 ELSE name END,
|
||||||
|
title = CASE WHEN $3 THEN $4 ELSE title END,
|
||||||
|
online = CASE WHEN $5 THEN $6 ELSE online END,
|
||||||
|
status_since = CASE WHEN $7 THEN $8 ELSE status_since END
|
||||||
|
WHERE domain=$9
|
||||||
|
AND (($1 AND name IS NOT $2)
|
||||||
|
OR ($3 AND title IS NOT $4)
|
||||||
|
OR ($5 AND online IS NOT $6)
|
||||||
|
OR ($7 AND status_since IS NOT $8))"""
|
||||||
|
async with self.db.acquire() as conn:
|
||||||
|
await conn.execute(
|
||||||
|
query,
|
||||||
|
update_name,
|
||||||
|
name_value,
|
||||||
|
update_title,
|
||||||
|
title_value,
|
||||||
|
update_online,
|
||||||
|
online_value,
|
||||||
|
update_status_since,
|
||||||
|
status_since_value,
|
||||||
|
domain,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def delete(self, domain: str) -> None:
|
||||||
|
"""Delete a stream record from the database.
|
||||||
|
|
||||||
|
:param domain: The stream domain.
|
||||||
|
"""
|
||||||
|
query = "DELETE FROM streams WHERE domain=$1"
|
||||||
|
async with self.db.acquire() as conn:
|
||||||
|
await conn.execute(query, domain)
|
||||||
|
|
||||||
|
async def increment_failure_counter(self, domain: str) -> None:
|
||||||
|
"""Increment the failure counter for a stream by 1.
|
||||||
|
|
||||||
|
:param domain: The stream domain.
|
||||||
|
"""
|
||||||
|
query = """UPDATE streams
|
||||||
|
SET failure_counter = failure_counter + 1
|
||||||
|
WHERE domain=$1"""
|
||||||
|
async with self.db.acquire() as conn:
|
||||||
|
await conn.execute(query, domain)
|
||||||
|
|
||||||
|
async def reset_failure_counter(self, domain: str) -> None:
|
||||||
|
"""Reset the failure counter for a stream to 0.
|
||||||
|
|
||||||
|
:param domain: The stream domain.
|
||||||
|
"""
|
||||||
|
query = """UPDATE streams
|
||||||
|
SET failure_counter = 0
|
||||||
|
WHERE domain=$1 AND failure_counter != 0"""
|
||||||
|
async with self.db.acquire() as conn:
|
||||||
|
await conn.execute(query, domain)
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionRepository:
|
||||||
|
"""Repository for managing stream subscriptions in the database."""
|
||||||
|
|
||||||
|
def __init__(self, database: Database) -> None:
|
||||||
|
"""Initialize the subscription repository.
|
||||||
|
|
||||||
|
:param database: The maubot database instance.
|
||||||
|
"""
|
||||||
|
self.db: Any = database
|
||||||
|
|
||||||
|
async def add(self, domain: str, room_id: str) -> None:
|
||||||
|
"""Add a subscription for a room to a stream.
|
||||||
|
|
||||||
|
:param domain: The stream domain.
|
||||||
|
:param room_id: The Matrix room ID.
|
||||||
|
:raises AlreadySubscribedError: If subscription already exists.
|
||||||
|
"""
|
||||||
|
query = """INSERT INTO subscriptions (stream_domain, room_id)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (room_id, stream_domain) DO NOTHING"""
|
||||||
|
async with self.db.acquire() as conn:
|
||||||
|
result = await conn.execute(query, domain, room_id)
|
||||||
|
if int(result.rowcount) == 0:
|
||||||
|
raise AlreadySubscribedError(domain)
|
||||||
|
|
||||||
|
async def remove(self, domain: str, room_id: str) -> None:
|
||||||
|
"""Remove a subscription for a room from a stream.
|
||||||
|
|
||||||
|
:param domain: The stream domain.
|
||||||
|
:param room_id: The Matrix room ID.
|
||||||
|
:raises NotSubscribedError: If no subscription exists.
|
||||||
|
"""
|
||||||
|
query = "DELETE FROM subscriptions WHERE stream_domain=$1 AND room_id=$2"
|
||||||
|
async with self.db.acquire() as conn:
|
||||||
|
result = await conn.execute(query, domain, room_id)
|
||||||
|
if int(result.rowcount) == 0:
|
||||||
|
raise NotSubscribedError(domain)
|
||||||
|
|
||||||
|
async def delete_all_for_domain(self, domain: str) -> int:
|
||||||
|
"""Delete all subscriptions for a given stream domain.
|
||||||
|
|
||||||
|
:param domain: The stream domain.
|
||||||
|
:return: Number of subscriptions deleted.
|
||||||
|
"""
|
||||||
|
query = "DELETE FROM subscriptions WHERE stream_domain=$1"
|
||||||
|
async with self.db.acquire() as conn:
|
||||||
|
result = await conn.execute(query, domain)
|
||||||
|
return int(result.rowcount)
|
||||||
|
|
||||||
|
async def get_subscribed_rooms(self, domain: str) -> list[str]:
|
||||||
|
"""Get all room IDs subscribed to a stream.
|
||||||
|
|
||||||
|
:param domain: The stream domain.
|
||||||
|
:return: List of room IDs.
|
||||||
|
"""
|
||||||
|
query = "SELECT room_id FROM subscriptions WHERE stream_domain=$1"
|
||||||
|
async with self.db.acquire() as conn:
|
||||||
|
results = await conn.fetch(query, domain)
|
||||||
|
return [row["room_id"] for row in results]
|
||||||
|
|
||||||
|
async def get_subscribed_streams_for_room(self, room_id: str) -> list[str]:
|
||||||
|
"""Get all stream domains that a room is subscribed to.
|
||||||
|
|
||||||
|
:param room_id: The Matrix room ID.
|
||||||
|
:return: List of stream domains.
|
||||||
|
"""
|
||||||
|
query = "SELECT stream_domain FROM subscriptions WHERE room_id=$1"
|
||||||
|
async with self.db.acquire() as conn:
|
||||||
|
results = await conn.fetch(query, room_id)
|
||||||
|
return [row["stream_domain"] for row in results]
|
||||||
|
|
||||||
|
async def has_room_subscriptions(self, room_id: str) -> bool:
|
||||||
|
"""Check whether a room has any subscriptions."""
|
||||||
|
query = "SELECT 1 FROM subscriptions WHERE room_id=$1 LIMIT 1"
|
||||||
|
async with self.db.acquire() as conn:
|
||||||
|
result = await conn.fetchrow(query, room_id)
|
||||||
|
return result is not None
|
||||||
|
|
||||||
|
async def get_room_subscriptions(self, room_id: str) -> list[RoomSubscription]:
|
||||||
|
"""Get resolved stream subscriptions for a room ordered by domain.
|
||||||
|
|
||||||
|
:param room_id: The Matrix room ID.
|
||||||
|
:return: Subscriptions with stream state attached.
|
||||||
|
"""
|
||||||
|
query = """SELECT streams.*
|
||||||
|
FROM subscriptions
|
||||||
|
JOIN streams ON streams.domain = subscriptions.stream_domain
|
||||||
|
WHERE subscriptions.room_id=$1
|
||||||
|
ORDER BY streams.domain"""
|
||||||
|
async with self.db.acquire() as conn:
|
||||||
|
results = await conn.fetch(query, room_id)
|
||||||
|
return [
|
||||||
|
RoomSubscription(
|
||||||
|
domain=row["domain"],
|
||||||
|
stream_state=StreamState.from_db_row(row),
|
||||||
|
)
|
||||||
|
for row in results
|
||||||
|
]
|
||||||
|
|
||||||
|
async def get_live_room_subscriptions(self, room_id: str) -> list[RoomSubscription]:
|
||||||
|
"""Get resolved live stream subscriptions for a room ordered by domain."""
|
||||||
|
query = """SELECT streams.*
|
||||||
|
FROM subscriptions
|
||||||
|
JOIN streams ON streams.domain = subscriptions.stream_domain
|
||||||
|
WHERE subscriptions.room_id=$1
|
||||||
|
AND streams.online=true
|
||||||
|
AND streams.failure_counter <= $2
|
||||||
|
ORDER BY streams.domain"""
|
||||||
|
async with self.db.acquire() as conn:
|
||||||
|
results = await conn.fetch(query, room_id, UNKNOWN_STATUS_THRESHOLD)
|
||||||
|
return [
|
||||||
|
RoomSubscription(
|
||||||
|
domain=row["domain"],
|
||||||
|
stream_state=StreamState.from_db_row(row),
|
||||||
|
)
|
||||||
|
for row in results
|
||||||
|
]
|
||||||
|
|
||||||
|
async def get_all_subscribed_domains(self) -> list[str]:
|
||||||
|
"""Get all unique stream domains that have at least one subscription.
|
||||||
|
|
||||||
|
:return: List of stream domains.
|
||||||
|
"""
|
||||||
|
query = "SELECT DISTINCT stream_domain FROM subscriptions"
|
||||||
|
async with self.db.acquire() as conn:
|
||||||
|
results = await conn.fetch(query)
|
||||||
|
return [row["stream_domain"] for row in results]
|
||||||
|
|
||||||
|
async def count_by_domain(self, domain: str) -> int:
|
||||||
|
"""Count the number of subscriptions for a given stream domain.
|
||||||
|
|
||||||
|
:param domain: The stream domain.
|
||||||
|
:return: Number of subscriptions.
|
||||||
|
"""
|
||||||
|
query = "SELECT COUNT(*) FROM subscriptions WHERE stream_domain=$1"
|
||||||
|
async with self.db.acquire() as conn:
|
||||||
|
result = await conn.fetchrow(query, domain)
|
||||||
|
return int(result[0])
|
||||||
|
|
||||||
|
async def count_by_domains(self, domains: list[str]) -> dict[str, int]:
|
||||||
|
"""Count subscriptions for each requested stream domain.
|
||||||
|
|
||||||
|
:param domains: The stream domains to count subscriptions for.
|
||||||
|
:return: Mapping from each requested domain to its subscription count.
|
||||||
|
"""
|
||||||
|
if not domains:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
counts = dict.fromkeys(domains, 0)
|
||||||
|
query = """SELECT stream_domain, COUNT(*) AS subscription_count
|
||||||
|
FROM subscriptions
|
||||||
|
GROUP BY stream_domain"""
|
||||||
|
async with self.db.acquire() as conn:
|
||||||
|
results = await conn.fetch(query)
|
||||||
|
|
||||||
|
for row in results:
|
||||||
|
domain = row["stream_domain"]
|
||||||
|
if domain in counts:
|
||||||
|
counts[domain] = int(row["subscription_count"])
|
||||||
|
return counts
|
||||||
+252
-170
@@ -16,23 +16,149 @@
|
|||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import time
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from enum import Enum, auto
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from .models import StreamState, StreamStatus, UpdateResult
|
from .types import (
|
||||||
from .utils import (
|
StreamConfigObservation,
|
||||||
CLEANUP_DELETE_THRESHOLD,
|
StreamState,
|
||||||
CLEANUP_WARNING_THRESHOLD,
|
StreamStateObservation,
|
||||||
TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN,
|
StreamStatus,
|
||||||
should_query_stream,
|
UpdateResult,
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from .database import StreamRepository, SubscriptionRepository
|
|
||||||
from .metrics import MetricsService
|
from .metrics import MetricsService
|
||||||
from .notification_service import NotificationService
|
from .notification_service import NotificationService
|
||||||
from .owncast_client import OwncastClient
|
from .owncast_client import OwncastClient
|
||||||
|
from .repository import StreamRepository, SubscriptionRepository
|
||||||
|
|
||||||
|
|
||||||
|
_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 <= _QUERY_EVERY_MINUTE_FAILURE_LIMIT:
|
||||||
|
return True
|
||||||
|
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 _StreamTransitionKind(Enum):
|
||||||
|
"""High-level status transition from stored state to fresh observation."""
|
||||||
|
|
||||||
|
FIRST_OBSERVATION = auto()
|
||||||
|
WENT_LIVE = auto()
|
||||||
|
WENT_OFFLINE = auto()
|
||||||
|
TITLE_CHANGED = auto()
|
||||||
|
STATUS_UNCHANGED = auto()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class _StreamTransition:
|
||||||
|
"""Classified stream transition and timestamp metadata."""
|
||||||
|
|
||||||
|
kind: _StreamTransitionKind
|
||||||
|
status_since: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class _NotificationKind(Enum):
|
||||||
|
"""Live/title notification action for a transition."""
|
||||||
|
|
||||||
|
NONE = auto()
|
||||||
|
LIVE = auto()
|
||||||
|
TITLE_CHANGE = auto()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def should_notify(self) -> bool:
|
||||||
|
"""Return whether a live/title-change notification should be attempted."""
|
||||||
|
return self is not _NotificationKind.NONE
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_transition(
|
||||||
|
old_state: StreamState, observation: StreamStateObservation
|
||||||
|
) -> _StreamTransition:
|
||||||
|
"""Classify the transition from the stored state to the latest observation."""
|
||||||
|
if old_state.status_since is None:
|
||||||
|
return _StreamTransition(
|
||||||
|
kind=_StreamTransitionKind.FIRST_OBSERVATION,
|
||||||
|
status_since=observation.observed_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
if old_state.online != observation.online:
|
||||||
|
return _StreamTransition(
|
||||||
|
kind=(
|
||||||
|
_StreamTransitionKind.WENT_LIVE
|
||||||
|
if observation.online
|
||||||
|
else _StreamTransitionKind.WENT_OFFLINE
|
||||||
|
),
|
||||||
|
status_since=observation.observed_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
if old_state.online and old_state.title != observation.title:
|
||||||
|
return _StreamTransition(
|
||||||
|
kind=_StreamTransitionKind.TITLE_CHANGED,
|
||||||
|
status_since=old_state.status_since,
|
||||||
|
)
|
||||||
|
|
||||||
|
return _StreamTransition(
|
||||||
|
kind=_StreamTransitionKind.STATUS_UNCHANGED,
|
||||||
|
status_since=old_state.status_since,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _classify_notification(
|
||||||
|
transition: _StreamTransition,
|
||||||
|
old_state: StreamState,
|
||||||
|
observation: StreamStateObservation,
|
||||||
|
*,
|
||||||
|
offline_duration_seconds: int | None = None,
|
||||||
|
went_offline_after_last_notification: bool = False,
|
||||||
|
) -> _NotificationKind:
|
||||||
|
"""Classify the live/title notification policy for a transition.
|
||||||
|
|
||||||
|
The caller supplies timing context because it comes from the monitor's
|
||||||
|
monotonic offline cache and notification-service cache, not the transition.
|
||||||
|
"""
|
||||||
|
match transition.kind:
|
||||||
|
case _StreamTransitionKind.WENT_LIVE:
|
||||||
|
if offline_duration_seconds is not None and (
|
||||||
|
offline_duration_seconds < _TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN
|
||||||
|
):
|
||||||
|
if old_state.title != observation.title:
|
||||||
|
return _NotificationKind.TITLE_CHANGE
|
||||||
|
return _NotificationKind.NONE
|
||||||
|
|
||||||
|
return _NotificationKind.LIVE
|
||||||
|
|
||||||
|
case _StreamTransitionKind.TITLE_CHANGED:
|
||||||
|
if went_offline_after_last_notification:
|
||||||
|
return _NotificationKind.LIVE
|
||||||
|
return _NotificationKind.TITLE_CHANGE
|
||||||
|
|
||||||
|
case _:
|
||||||
|
return _NotificationKind.NONE
|
||||||
|
|
||||||
|
|
||||||
class StreamMonitor:
|
class StreamMonitor:
|
||||||
@@ -79,8 +205,7 @@ class StreamMonitor:
|
|||||||
|
|
||||||
total_streams = len(subscribed_domains)
|
total_streams = len(subscribed_domains)
|
||||||
|
|
||||||
# Build a list of async tasks for each stream domain
|
# Run all stream updates concurrently and collect results.
|
||||||
# Run all stream updates in parallel and collect results
|
|
||||||
results = await asyncio.gather(
|
results = await asyncio.gather(
|
||||||
*(self.update_stream(domain) for domain in subscribed_domains),
|
*(self.update_stream(domain) for domain in subscribed_domains),
|
||||||
return_exceptions=True,
|
return_exceptions=True,
|
||||||
@@ -92,7 +217,8 @@ class StreamMonitor:
|
|||||||
for domain, result in zip(subscribed_domains, results, strict=True):
|
for domain, result in zip(subscribed_domains, results, strict=True):
|
||||||
if isinstance(result, BaseException):
|
if isinstance(result, BaseException):
|
||||||
self.log.exception(
|
self.log.exception(
|
||||||
f"[{domain}] Unhandled exception during stream update.",
|
"[%s] Unhandled exception during stream update.",
|
||||||
|
domain,
|
||||||
exc_info=result,
|
exc_info=result,
|
||||||
)
|
)
|
||||||
failed_checks += 1
|
failed_checks += 1
|
||||||
@@ -102,13 +228,19 @@ class StreamMonitor:
|
|||||||
failed_checks += 1
|
failed_checks += 1
|
||||||
|
|
||||||
self.log.debug(
|
self.log.debug(
|
||||||
f"Update complete. {successful_checks}/{total_streams} succeeded, "
|
"Update complete. %s/%s succeeded, %s failed.",
|
||||||
f"{failed_checks} failed."
|
successful_checks,
|
||||||
|
total_streams,
|
||||||
|
failed_checks,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
subscription_counts = await self.subscription_repo.count_by_domains(
|
||||||
|
subscribed_domains
|
||||||
|
)
|
||||||
for domain in subscribed_domains:
|
for domain in subscribed_domains:
|
||||||
count = await self.subscription_repo.count_by_domain(domain)
|
self.metrics.set_subscription_count(
|
||||||
self.metrics.set_subscription_count(domain, count)
|
domain, subscription_counts.get(domain, 0)
|
||||||
|
)
|
||||||
|
|
||||||
return UpdateResult(
|
return UpdateResult(
|
||||||
total_streams=total_streams,
|
total_streams=total_streams,
|
||||||
@@ -119,9 +251,9 @@ class StreamMonitor:
|
|||||||
async def update_stream(self, domain: str) -> bool:
|
async def update_stream(self, domain: str) -> bool:
|
||||||
"""Update the state of a stream and send notifications as needed.
|
"""Update the state of a stream and send notifications as needed.
|
||||||
|
|
||||||
Sends notifications to subscribed Matrix rooms if a stream goes
|
Sends notifications to subscribed Matrix rooms when a stream goes
|
||||||
live. Implements progressive backoff for connection failures and
|
live or changes title. Implements progressive backoff for connection
|
||||||
auto-cleanup for dead instances.
|
failures and auto-cleanup for dead instances.
|
||||||
|
|
||||||
:param domain: The domain of the stream to update.
|
:param domain: The domain of the stream to update.
|
||||||
:return: True if check succeeded or was skipped, False on failure.
|
:return: True if check succeeded or was skipped, False on failure.
|
||||||
@@ -134,12 +266,13 @@ class StreamMonitor:
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
# Check if we should query this stream based on backoff schedule
|
# Check if we should query this stream based on backoff schedule
|
||||||
if not should_query_stream(failure_counter):
|
if not _should_query_stream(failure_counter):
|
||||||
# Skip this cycle, increment counter to track time passage
|
# Skip this cycle, increment counter to track time passage
|
||||||
await self.stream_repo.increment_failure_counter(domain)
|
await self.stream_repo.increment_failure_counter(domain)
|
||||||
self.log.debug(
|
self.log.debug(
|
||||||
f"[{domain}] Skipping query due to backoff "
|
"[%s] Skipping query due to backoff (counter=%s)",
|
||||||
f"(counter={failure_counter + 1})"
|
domain,
|
||||||
|
failure_counter + 1,
|
||||||
)
|
)
|
||||||
# Check cleanup thresholds even when skipping query
|
# Check cleanup thresholds even when skipping query
|
||||||
await self._check_cleanup_thresholds(domain, failure_counter + 1)
|
await self._check_cleanup_thresholds(domain, failure_counter + 1)
|
||||||
@@ -150,25 +283,16 @@ class StreamMonitor:
|
|||||||
# Backoff is expected behavior, not a failure
|
# Backoff is expected behavior, not a failure
|
||||||
return True
|
return True
|
||||||
|
|
||||||
# Flag: first state update of a brand-new stream to avoid
|
|
||||||
# sending notifications if it's already live.
|
|
||||||
first_update = False
|
|
||||||
|
|
||||||
# Flag: whether to update the stream's state in the database.
|
|
||||||
# Used to avoid writes when state hasn't changed at all.
|
|
||||||
update_database = False
|
|
||||||
|
|
||||||
# The stream's latest configuration, if fetched during update.
|
|
||||||
stream_config = None
|
|
||||||
|
|
||||||
# Fetch the latest stream state from the server
|
# Fetch the latest stream state from the server
|
||||||
new_state = await self.owncast_client.get_stream_state(domain)
|
observation = await self.owncast_client.get_stream_state(domain)
|
||||||
|
|
||||||
# If the fetch failed, increment failure counter and skip the update
|
# If the fetch failed, increment failure counter and skip the update
|
||||||
if new_state is None:
|
if observation is None:
|
||||||
await self.stream_repo.increment_failure_counter(domain)
|
await self.stream_repo.increment_failure_counter(domain)
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
f"[{domain}] Connection failure (counter={failure_counter + 1})"
|
"[%s] Connection failure (counter=%s)",
|
||||||
|
domain,
|
||||||
|
failure_counter + 1,
|
||||||
)
|
)
|
||||||
# Check cleanup thresholds after connection failure
|
# Check cleanup thresholds after connection failure
|
||||||
await self._check_cleanup_thresholds(domain, failure_counter + 1)
|
await self._check_cleanup_thresholds(domain, failure_counter + 1)
|
||||||
@@ -186,153 +310,108 @@ class StreamMonitor:
|
|||||||
# Initialize timer cache entries to prevent KeyError on first access
|
# Initialize timer cache entries to prevent KeyError on first access
|
||||||
self.offline_timer_cache.setdefault(domain, 0)
|
self.offline_timer_cache.setdefault(domain, 0)
|
||||||
|
|
||||||
# Does the last known stream state lack connect/disconnect?
|
transition = _classify_transition(old_state, observation)
|
||||||
if (
|
# Notification policy may need runtime timing context in addition to
|
||||||
old_state.last_connect_time is None
|
# the stored state and latest observation.
|
||||||
and old_state.last_disconnect_time is None
|
offline_duration_seconds: int | None = None
|
||||||
):
|
went_offline_after_last_notification = False
|
||||||
# Yes, this is the first update. Don't send any notifications.
|
|
||||||
update_database = True
|
|
||||||
first_update = True
|
|
||||||
|
|
||||||
# Does the new state have a connect time but the old one not?
|
match transition.kind:
|
||||||
if (
|
case _StreamTransitionKind.FIRST_OBSERVATION:
|
||||||
new_state.last_connect_time is not None
|
self.log.info(
|
||||||
and old_state.last_connect_time is None
|
"[%s] Not sending notifications. This is the first state "
|
||||||
):
|
"update for this stream.",
|
||||||
# Yes! This stream is now live.
|
domain,
|
||||||
update_database = True
|
)
|
||||||
stream_config = await self.owncast_client.get_stream_config(domain)
|
case _StreamTransitionKind.WENT_LIVE:
|
||||||
|
self.log.info("[%s] Stream is now live!", domain)
|
||||||
self.log.info(f"[{domain}] Stream is now live!")
|
offline_duration_seconds = round(
|
||||||
|
|
||||||
# Calculate seconds since the stream last went offline
|
|
||||||
seconds_since_last_offline = round(
|
|
||||||
time.monotonic() - self.offline_timer_cache[domain]
|
time.monotonic() - self.offline_timer_cache[domain]
|
||||||
)
|
)
|
||||||
|
# Brief reconnects with the same title are treated as transient
|
||||||
# Have we queried this stream before?
|
# outages, not new live events.
|
||||||
if not first_update:
|
|
||||||
# Use fallback values if config fetch failed
|
|
||||||
stream_name = stream_config.name if stream_config else domain
|
|
||||||
stream_tags = stream_config.tags if stream_config else []
|
|
||||||
|
|
||||||
# Has this stream been offline for a short time?
|
|
||||||
if seconds_since_last_offline < TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN:
|
|
||||||
# Did the stream title change?
|
|
||||||
if old_state.title != new_state.title:
|
|
||||||
# Stream was briefly down; send title
|
|
||||||
# change notification.
|
|
||||||
await self.notification_service.notify_stream_live(
|
|
||||||
domain,
|
|
||||||
stream_name,
|
|
||||||
new_state.title or "",
|
|
||||||
stream_tags,
|
|
||||||
title_change=True,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Briefly offline, no title change. Skip.
|
|
||||||
self.log.info(
|
|
||||||
f"[{domain}] Not sending "
|
|
||||||
f"notifications. Stream was only "
|
|
||||||
f"offline for "
|
|
||||||
f"{seconds_since_last_offline} of "
|
|
||||||
f"{TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN}"
|
|
||||||
f" seconds and did not change its "
|
|
||||||
f"title."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Offline for a while. Send a normal notification.
|
|
||||||
await self.notification_service.notify_stream_live(
|
|
||||||
domain,
|
|
||||||
stream_name,
|
|
||||||
new_state.title or "",
|
|
||||||
stream_tags,
|
|
||||||
title_change=False,
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# No, this is the first time we're querying
|
|
||||||
self.log.info(
|
|
||||||
f"[{domain}] Not sending notifications. "
|
|
||||||
f"This is the first state update for "
|
|
||||||
f"this stream."
|
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
if (
|
||||||
new_state.last_connect_time is not None
|
offline_duration_seconds < _TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN
|
||||||
and old_state.last_connect_time is not None
|
and old_state.title == observation.title
|
||||||
):
|
):
|
||||||
# Did the stream title change mid-session?
|
self.log.info(
|
||||||
if old_state.title != new_state.title:
|
"[%s] Not sending notifications. Stream was only offline for "
|
||||||
self.log.info(f"[{domain}] Stream title was changed!")
|
"%s of %s seconds and did not change its title.",
|
||||||
update_database = True
|
domain,
|
||||||
|
offline_duration_seconds,
|
||||||
|
_TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN,
|
||||||
|
)
|
||||||
|
case _StreamTransitionKind.TITLE_CHANGED:
|
||||||
|
self.log.info("[%s] Stream title was changed!", domain)
|
||||||
|
# If we saw an offline event after the last notification, send
|
||||||
|
# a go-live notice instead of only a title-change notice.
|
||||||
|
went_offline_after_last_notification = self.offline_timer_cache[
|
||||||
|
domain
|
||||||
|
] > self.notification_service.get_last_notification_time(domain)
|
||||||
|
case _StreamTransitionKind.WENT_OFFLINE:
|
||||||
|
self.offline_timer_cache[domain] = time.monotonic()
|
||||||
|
self.log.info("[%s] Stream is now offline.", domain)
|
||||||
|
case _StreamTransitionKind.STATUS_UNCHANGED:
|
||||||
|
pass
|
||||||
|
|
||||||
|
notification_kind = _classify_notification(
|
||||||
|
transition,
|
||||||
|
old_state,
|
||||||
|
observation,
|
||||||
|
offline_duration_seconds=offline_duration_seconds,
|
||||||
|
went_offline_after_last_notification=went_offline_after_last_notification,
|
||||||
|
)
|
||||||
|
|
||||||
|
stream_config: StreamConfigObservation | None = None
|
||||||
|
observed_at = datetime.fromisoformat(observation.observed_at)
|
||||||
|
hourly_config_refresh_due = (
|
||||||
|
observed_at.tzinfo is not None and observed_at.astimezone(UTC).minute == 0
|
||||||
|
)
|
||||||
|
should_fetch_config = (
|
||||||
|
notification_kind.should_notify
|
||||||
|
or transition.kind is _StreamTransitionKind.FIRST_OBSERVATION
|
||||||
|
or hourly_config_refresh_due
|
||||||
|
)
|
||||||
|
if should_fetch_config:
|
||||||
stream_config = await self.owncast_client.get_stream_config(domain)
|
stream_config = await self.owncast_client.get_stream_config(domain)
|
||||||
|
|
||||||
# Use fallback values if config fetch failed
|
if notification_kind.should_notify:
|
||||||
stream_name = stream_config.name if stream_config else domain
|
stream_name = stream_config.name if stream_config else domain
|
||||||
stream_tags = stream_config.tags if stream_config else []
|
stream_tags = stream_config.tags if stream_config else ()
|
||||||
|
is_title_change = notification_kind is _NotificationKind.TITLE_CHANGE
|
||||||
|
|
||||||
# Was the last notification sent before the stream
|
|
||||||
# last went offline? If so, send a regular go-live
|
|
||||||
# instead of a title change to avoid confusion.
|
|
||||||
if self.offline_timer_cache[
|
|
||||||
domain
|
|
||||||
] > self.notification_service.get_last_notification_time(domain):
|
|
||||||
await self.notification_service.notify_stream_live(
|
await self.notification_service.notify_stream_live(
|
||||||
domain,
|
domain,
|
||||||
stream_name,
|
stream_name,
|
||||||
new_state.title or "",
|
observation.title or "",
|
||||||
stream_tags,
|
stream_tags,
|
||||||
title_change=False,
|
title_change=is_title_change,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.log.debug("[%s] Saving stream state if changed...", domain)
|
||||||
|
|
||||||
|
if stream_config is not None:
|
||||||
|
await self.stream_repo.update(
|
||||||
|
domain,
|
||||||
|
name=stream_config.name,
|
||||||
|
title=observation.title,
|
||||||
|
online=observation.online,
|
||||||
|
status_since=transition.status_since,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# No. Send a normal title change notification.
|
await self.stream_repo.update(
|
||||||
await self.notification_service.notify_stream_live(
|
|
||||||
domain,
|
domain,
|
||||||
stream_name,
|
title=observation.title,
|
||||||
new_state.title or "",
|
online=observation.online,
|
||||||
stream_tags,
|
status_since=transition.status_since,
|
||||||
title_change=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Did the stream go offline (old had connect, new doesn't)?
|
|
||||||
elif (
|
|
||||||
new_state.last_connect_time is None
|
|
||||||
and old_state.last_connect_time is not None
|
|
||||||
):
|
|
||||||
# Yep. This stream is now offline. Log it.
|
|
||||||
update_database = True
|
|
||||||
self.offline_timer_cache[domain] = time.monotonic()
|
|
||||||
self.log.info(f"[{domain}] Stream is now offline.")
|
|
||||||
|
|
||||||
# Update the database with current stream state, if needed.
|
|
||||||
if update_database:
|
|
||||||
# Ensure we have the stream config before updating
|
|
||||||
if stream_config is None:
|
|
||||||
stream_config = await self.owncast_client.get_stream_config(domain)
|
|
||||||
|
|
||||||
# Use fallback value if config fetch failed
|
|
||||||
stream_name = stream_config.name if stream_config else ""
|
|
||||||
|
|
||||||
self.log.debug(f"[{domain}] Updating stream state in database...")
|
|
||||||
|
|
||||||
# Create updated state object (title already truncated in new_state)
|
|
||||||
updated_state = StreamState(
|
|
||||||
domain=domain,
|
|
||||||
name=stream_name,
|
|
||||||
title=new_state.title,
|
|
||||||
last_connect_time=new_state.last_connect_time,
|
|
||||||
last_disconnect_time=new_state.last_disconnect_time,
|
|
||||||
)
|
|
||||||
|
|
||||||
await self.stream_repo.update(updated_state)
|
|
||||||
|
|
||||||
# All done.
|
# All done.
|
||||||
self.log.debug(f"[{domain}] State update completed.")
|
self.log.debug("[%s] State update completed.", domain)
|
||||||
if new_state.last_connect_time is not None:
|
stream_status = (
|
||||||
self.metrics.set_stream_status(domain, StreamStatus.ONLINE)
|
StreamStatus.ONLINE if observation.online else StreamStatus.OFFLINE
|
||||||
else:
|
)
|
||||||
self.metrics.set_stream_status(domain, StreamStatus.OFFLINE)
|
self.metrics.set_stream_status(domain, stream_status)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
async def _check_cleanup_thresholds(self, domain: str, counter: int) -> None:
|
async def _check_cleanup_thresholds(self, domain: str, counter: int) -> None:
|
||||||
@@ -342,17 +421,18 @@ class StreamMonitor:
|
|||||||
:param counter: The current failure counter value.
|
:param counter: The current failure counter value.
|
||||||
"""
|
"""
|
||||||
# Check for 83-day warning threshold
|
# Check for 83-day warning threshold
|
||||||
if counter == CLEANUP_WARNING_THRESHOLD:
|
if counter == _CLEANUP_WARNING_THRESHOLD:
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
f"[{domain}] Reached 83-day warning threshold. Sending cleanup warning."
|
"[%s] Reached 83-day warning threshold. Sending cleanup warning.",
|
||||||
|
domain,
|
||||||
)
|
)
|
||||||
await self.notification_service.send_cleanup_warning(domain)
|
await self.notification_service.send_cleanup_warning(domain)
|
||||||
|
|
||||||
# Check for 90-day deletion threshold
|
# Check for 90-day deletion threshold
|
||||||
if counter >= CLEANUP_DELETE_THRESHOLD:
|
if counter >= _CLEANUP_DELETE_THRESHOLD:
|
||||||
self.log.warning(
|
self.log.warning(
|
||||||
f"[{domain}] Reached 90-day deletion threshold."
|
"[%s] Reached 90-day deletion threshold. Removing all subscriptions.",
|
||||||
f" Removing all subscriptions."
|
domain,
|
||||||
)
|
)
|
||||||
# Send deletion notification
|
# Send deletion notification
|
||||||
await self.notification_service.send_cleanup_deletion(domain)
|
await self.notification_service.send_cleanup_deletion(domain)
|
||||||
@@ -362,10 +442,12 @@ class StreamMonitor:
|
|||||||
|
|
||||||
# Delete the stream record
|
# Delete the stream record
|
||||||
await self.stream_repo.delete(domain)
|
await self.stream_repo.delete(domain)
|
||||||
|
self.offline_timer_cache.pop(domain, None)
|
||||||
|
self.notification_service.clear_notification_state(domain)
|
||||||
|
|
||||||
self.log.info(
|
self.log.info(
|
||||||
f"[{domain}] Cleanup complete. "
|
"[%s] Cleanup complete. Deleted %s subscriptions and stream record.",
|
||||||
f"Deleted {deleted_count} subscriptions "
|
domain,
|
||||||
f"and stream record."
|
deleted_count,
|
||||||
)
|
)
|
||||||
self.metrics.remove_stream(domain)
|
self.metrics.remove_stream(domain)
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
# Copyright 2026 Logan Fick
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Business logic for managing room stream subscriptions."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from .types import (
|
||||||
|
InvalidOwncastInstanceError,
|
||||||
|
RoomSubscription,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .owncast_client import OwncastClient
|
||||||
|
from .repository import StreamRepository, SubscriptionRepository
|
||||||
|
|
||||||
|
|
||||||
|
_DOMAIN_CLEANUP_RE = re.compile(r"[^a-z0-9.-]")
|
||||||
|
|
||||||
|
|
||||||
|
def _domainify(url: str) -> str:
|
||||||
|
"""Extract and sanitize a domain from user input."""
|
||||||
|
url = url.strip()
|
||||||
|
|
||||||
|
if "@" in url:
|
||||||
|
url = url.rsplit("@", 1)[1]
|
||||||
|
|
||||||
|
if not url.startswith(("http://", "https://", "//")):
|
||||||
|
url = f"//{url}"
|
||||||
|
|
||||||
|
parsed = urlparse(url)
|
||||||
|
domain = (parsed.netloc or parsed.path).lower()
|
||||||
|
domain = domain.partition(":")[0].partition("/")[0]
|
||||||
|
return _DOMAIN_CLEANUP_RE.sub("", domain).strip(".-")
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionManager:
|
||||||
|
"""Coordinates subscription use cases between handlers and repositories."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
owncast_client: OwncastClient,
|
||||||
|
stream_repo: StreamRepository,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
logger: logging.Logger,
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the subscription manager."""
|
||||||
|
self.owncast_client = owncast_client
|
||||||
|
self.stream_repo = stream_repo
|
||||||
|
self.subscription_repo = subscription_repo
|
||||||
|
self.log = logger
|
||||||
|
|
||||||
|
async def subscribe(self, room_id: str, url: str) -> str:
|
||||||
|
"""Subscribe a room to stream notifications and return the stream domain.
|
||||||
|
|
||||||
|
:param room_id: Matrix room ID to subscribe.
|
||||||
|
:param url: User-supplied Owncast URL, domain, or Fediverse-style address.
|
||||||
|
:return: Normalized stream domain.
|
||||||
|
:raises InvalidOwncastInstanceError: If first-time validation fails.
|
||||||
|
:raises AlreadySubscribedError: If the room is already subscribed.
|
||||||
|
"""
|
||||||
|
stream_domain = _domainify(url)
|
||||||
|
|
||||||
|
subscription_count = await self.subscription_repo.count_by_domain(stream_domain)
|
||||||
|
if subscription_count == 0:
|
||||||
|
is_valid = await self.owncast_client.validate_instance(stream_domain)
|
||||||
|
if not is_valid:
|
||||||
|
raise InvalidOwncastInstanceError(stream_domain)
|
||||||
|
|
||||||
|
await self.subscription_repo.add(stream_domain, room_id)
|
||||||
|
|
||||||
|
if await self.stream_repo.create(stream_domain):
|
||||||
|
self.log.info("[%s] Discovered new stream!", stream_domain)
|
||||||
|
|
||||||
|
self.log.info("[%s] Subscription added for room %s.", stream_domain, room_id)
|
||||||
|
return stream_domain
|
||||||
|
|
||||||
|
async def unsubscribe(self, room_id: str, url: str) -> str:
|
||||||
|
"""Remove a room subscription and return the stream domain.
|
||||||
|
|
||||||
|
:param room_id: Matrix room ID to unsubscribe.
|
||||||
|
:param url: User-supplied Owncast URL, domain, or Fediverse-style address.
|
||||||
|
:return: Normalized stream domain.
|
||||||
|
:raises NotSubscribedError: If no subscription was removed.
|
||||||
|
"""
|
||||||
|
stream_domain = _domainify(url)
|
||||||
|
await self.subscription_repo.remove(stream_domain, room_id)
|
||||||
|
|
||||||
|
self.log.info("[%s] Subscription removed for room %s.", stream_domain, room_id)
|
||||||
|
return stream_domain
|
||||||
|
|
||||||
|
async def list_room_subscriptions(self, room_id: str) -> list[RoomSubscription]:
|
||||||
|
"""Return stream subscriptions for a room with display state attached."""
|
||||||
|
return await self.subscription_repo.get_room_subscriptions(room_id)
|
||||||
|
|
||||||
|
async def has_room_subscriptions(self, room_id: str) -> bool:
|
||||||
|
"""Return whether the room has any stream subscriptions."""
|
||||||
|
return await self.subscription_repo.has_room_subscriptions(room_id)
|
||||||
|
|
||||||
|
async def list_live_room_subscriptions(
|
||||||
|
self, room_id: str
|
||||||
|
) -> list[RoomSubscription]:
|
||||||
|
"""Return only currently online stream subscriptions for a room."""
|
||||||
|
return await self.subscription_repo.get_live_room_subscriptions(room_id)
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
# Copyright 2026 Logan Fick
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Data containers and domain errors for OwncastSentry."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
UNKNOWN_STATUS_THRESHOLD = 15
|
||||||
|
|
||||||
|
# Maximum field lengths based on Owncast's admin config constants.
|
||||||
|
# Source: https://github.com/owncast/owncast/blob/master/web/utils/config-constants.tsx
|
||||||
|
_MAX_INSTANCE_TITLE_LENGTH = 255 # TEXTFIELD_PROPS_SERVER_NAME maxLength
|
||||||
|
_MAX_STREAM_TITLE_LENGTH = 100 # TEXTFIELD_PROPS_STREAM_TITLE maxLength
|
||||||
|
_MAX_TAG_LENGTH = 24 # tag maxLength
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidApiResponseError(ValueError):
|
||||||
|
"""The Owncast API response did not match the expected shape."""
|
||||||
|
|
||||||
|
|
||||||
|
def _require_field(response: dict[str, Any], field: str) -> Any:
|
||||||
|
"""Return a required API response field or raise if missing."""
|
||||||
|
try:
|
||||||
|
return response[field]
|
||||||
|
except KeyError as e:
|
||||||
|
raise InvalidApiResponseError(f"missing field: {field}") from e
|
||||||
|
|
||||||
|
|
||||||
|
def _require_str(response: dict[str, Any], field: str) -> str:
|
||||||
|
"""Return a required string API response field."""
|
||||||
|
value = _require_field(response, field)
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise InvalidApiResponseError(f"{field} must be a string")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_config_str(response: dict[str, Any], field: str) -> str:
|
||||||
|
"""Return an optional config string, defaulting to empty when absent."""
|
||||||
|
value = response.get(field, "")
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise InvalidApiResponseError(f"{field} must be a string")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _optional_tag_list(response: dict[str, Any]) -> list[str]:
|
||||||
|
"""Return optional config tags, defaulting to an empty list when absent."""
|
||||||
|
value = response.get("tags", [])
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise InvalidApiResponseError("tags must be a list")
|
||||||
|
if not all(isinstance(tag, str) for tag in value):
|
||||||
|
raise InvalidApiResponseError("tags must contain only strings")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(text: str, max_length: int) -> str:
|
||||||
|
"""Truncate text to a maximum length."""
|
||||||
|
if len(text) <= max_length:
|
||||||
|
return text
|
||||||
|
return text[:max_length]
|
||||||
|
|
||||||
|
|
||||||
|
def format_status_since(timestamp: datetime) -> str:
|
||||||
|
"""Format a status timestamp using the package's canonical UTC format."""
|
||||||
|
if timestamp.tzinfo is None:
|
||||||
|
timestamp = timestamp.replace(tzinfo=UTC)
|
||||||
|
return timestamp.astimezone(UTC).isoformat(timespec="seconds")
|
||||||
|
|
||||||
|
|
||||||
|
def _current_observed_at() -> str:
|
||||||
|
"""Return the current UTC time in the package's timestamp format."""
|
||||||
|
return format_status_since(datetime.now(UTC))
|
||||||
|
|
||||||
|
|
||||||
|
class StreamStatus(Enum):
|
||||||
|
"""Represents the status of a stream."""
|
||||||
|
|
||||||
|
ONLINE = "online"
|
||||||
|
OFFLINE = "offline"
|
||||||
|
UNKNOWN = "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class StreamStateObservation:
|
||||||
|
"""Status API sample fetched from an Owncast instance.
|
||||||
|
|
||||||
|
This records what `/api/status` reported during one request. `observed_at`
|
||||||
|
belongs to that fetch; the monitor decides whether that time starts a new
|
||||||
|
persisted online/offline period in `StreamState`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
domain: str
|
||||||
|
title: str | None = None
|
||||||
|
online: bool = False
|
||||||
|
observed_at: str = field(default_factory=_current_observed_at)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_api_response(
|
||||||
|
cls, response: dict[str, Any], domain: str
|
||||||
|
) -> StreamStateObservation:
|
||||||
|
"""Create a StreamStateObservation from a status API response.
|
||||||
|
|
||||||
|
:param response: API response as a dictionary (camelCase keys).
|
||||||
|
:param domain: The stream domain.
|
||||||
|
:return: StreamStateObservation instance.
|
||||||
|
:raises InvalidApiResponseError: If the response shape is invalid.
|
||||||
|
"""
|
||||||
|
stream_title = _require_str(response, "streamTitle")
|
||||||
|
online = _require_field(response, "online")
|
||||||
|
if not isinstance(online, bool):
|
||||||
|
raise InvalidApiResponseError("online must be a boolean")
|
||||||
|
|
||||||
|
return cls(
|
||||||
|
domain=domain,
|
||||||
|
title=_truncate(stream_title, _MAX_STREAM_TITLE_LENGTH),
|
||||||
|
online=online,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class StreamState:
|
||||||
|
"""Stream record remembered by the repository.
|
||||||
|
|
||||||
|
States include persisted display data, the current online/offline period,
|
||||||
|
and failure counters used to derive user-facing status. `status_since`
|
||||||
|
records when the persisted online/offline period began.
|
||||||
|
"""
|
||||||
|
|
||||||
|
domain: str
|
||||||
|
name: str | None = None
|
||||||
|
title: str | None = None
|
||||||
|
online: bool = False
|
||||||
|
status_since: str | None = None
|
||||||
|
failure_counter: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def status(self) -> StreamStatus:
|
||||||
|
"""Derive stream status from failure count and online state.
|
||||||
|
|
||||||
|
Returns UNKNOWN if failures exceed the threshold, ONLINE if the
|
||||||
|
stream is online, or OFFLINE otherwise.
|
||||||
|
"""
|
||||||
|
if self.failure_counter > UNKNOWN_STATUS_THRESHOLD:
|
||||||
|
return StreamStatus.UNKNOWN
|
||||||
|
if self.online:
|
||||||
|
return StreamStatus.ONLINE
|
||||||
|
return StreamStatus.OFFLINE
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_db_row(cls, row: dict[str, Any]) -> StreamState:
|
||||||
|
"""Create a StreamState from a database row.
|
||||||
|
|
||||||
|
:param row: Database row as a dictionary.
|
||||||
|
:return: StreamState instance.
|
||||||
|
"""
|
||||||
|
return cls(
|
||||||
|
domain=row["domain"],
|
||||||
|
name=row["name"],
|
||||||
|
title=row["title"],
|
||||||
|
online=bool(row["online"]),
|
||||||
|
status_since=row["status_since"],
|
||||||
|
failure_counter=row["failure_counter"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class StreamConfigObservation:
|
||||||
|
"""Config API sample fetched from an Owncast instance.
|
||||||
|
|
||||||
|
This records display metadata from one `/api/config` request. It is used for
|
||||||
|
notification text and may refresh persisted display fields independently of
|
||||||
|
the stream's online/offline state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
name: str = ""
|
||||||
|
tags: tuple[str, ...] = ()
|
||||||
|
observed_at: str = field(default_factory=_current_observed_at)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_api_response(cls, response: dict[str, Any]) -> StreamConfigObservation:
|
||||||
|
"""Create a StreamConfigObservation from an API response.
|
||||||
|
|
||||||
|
:param response: API response as a dictionary.
|
||||||
|
:return: StreamConfigObservation instance.
|
||||||
|
:raises InvalidApiResponseError: If the response shape is invalid.
|
||||||
|
"""
|
||||||
|
# Apply Owncast's instance name limit.
|
||||||
|
name = _truncate(
|
||||||
|
_optional_config_str(response, "name"), _MAX_INSTANCE_TITLE_LENGTH
|
||||||
|
)
|
||||||
|
|
||||||
|
# Apply Owncast's per-tag limit.
|
||||||
|
raw_tags = _optional_tag_list(response)
|
||||||
|
tags = tuple([_truncate(tag, _MAX_TAG_LENGTH) for tag in raw_tags])
|
||||||
|
|
||||||
|
return cls(name=name, tags=tags)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class UpdateResult:
|
||||||
|
"""Result of a stream update cycle."""
|
||||||
|
|
||||||
|
total_streams: int
|
||||||
|
successful_checks: int
|
||||||
|
failed_checks: int
|
||||||
|
|
||||||
|
|
||||||
|
class SubscriptionError(Exception):
|
||||||
|
"""Base class for subscription domain errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidOwncastInstanceError(SubscriptionError):
|
||||||
|
"""The requested domain is not a reachable Owncast instance."""
|
||||||
|
|
||||||
|
def __init__(self, domain: str) -> None:
|
||||||
|
"""Initialize with the rejected stream domain."""
|
||||||
|
self.domain = domain
|
||||||
|
super().__init__(f"invalid Owncast instance: {domain}")
|
||||||
|
|
||||||
|
|
||||||
|
class AlreadySubscribedError(SubscriptionError):
|
||||||
|
"""The room is already subscribed to the stream."""
|
||||||
|
|
||||||
|
def __init__(self, domain: str) -> None:
|
||||||
|
"""Initialize with the duplicate stream domain."""
|
||||||
|
self.domain = domain
|
||||||
|
super().__init__(f"already subscribed: {domain}")
|
||||||
|
|
||||||
|
|
||||||
|
class NotSubscribedError(SubscriptionError):
|
||||||
|
"""The room is not subscribed to the stream."""
|
||||||
|
|
||||||
|
def __init__(self, domain: str) -> None:
|
||||||
|
"""Initialize with the missing stream domain."""
|
||||||
|
self.domain = domain
|
||||||
|
super().__init__(f"not subscribed: {domain}")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RoomSubscription:
|
||||||
|
"""A stream subscription resolved with the stream state used for display."""
|
||||||
|
|
||||||
|
domain: str
|
||||||
|
stream_state: StreamState
|
||||||
@@ -1,205 +0,0 @@
|
|||||||
# Copyright 2026 Logan Fick
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
|
|
||||||
"""Utility functions and constants for OwncastSentry."""
|
|
||||||
|
|
||||||
import re
|
|
||||||
from urllib.parse import urlparse
|
|
||||||
|
|
||||||
# Path to the GetStatus API call on Owncast instances
|
|
||||||
OWNCAST_STATUS_PATH = "/api/status"
|
|
||||||
|
|
||||||
# Path to GetWebConfig API call on Owncast instances
|
|
||||||
OWNCAST_CONFIG_PATH = "/api/config"
|
|
||||||
|
|
||||||
# Fields that must be present in an Owncast status API response
|
|
||||||
REQUIRED_STATUS_FIELDS = frozenset(
|
|
||||||
{
|
|
||||||
"lastConnectTime",
|
|
||||||
"lastDisconnectTime",
|
|
||||||
"streamTitle",
|
|
||||||
"online",
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def user_agent(version: str) -> str:
|
|
||||||
"""Build the User-Agent header string for HTTP requests.
|
|
||||||
|
|
||||||
:param version: The plugin version string.
|
|
||||||
:return: A formatted User-Agent string.
|
|
||||||
"""
|
|
||||||
return (
|
|
||||||
f"OwncastSentry/{version}"
|
|
||||||
" (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# Hard minimum amount of time between when notifications can be sent
|
|
||||||
# for a stream. Prevents spamming notifications for glitchy or
|
|
||||||
# malicious streams.
|
|
||||||
SECONDS_BETWEEN_NOTIFICATIONS = 20 * 60 # 20 minutes in seconds
|
|
||||||
|
|
||||||
# After a stream goes offline, a timer is started. Then, ...
|
|
||||||
# - If a stream comes back online with the same title within this
|
|
||||||
# time, no notification is sent.
|
|
||||||
# - If a stream comes back online with a different title, a rename
|
|
||||||
# notification is sent.
|
|
||||||
# - If this time period passes entirely and a stream comes back
|
|
||||||
# online after, it's treated as regular going live.
|
|
||||||
TEMPORARY_OFFLINE_NOTIFICATION_COOLDOWN = 7 * 60 # 7 min in seconds
|
|
||||||
|
|
||||||
# Auto-cleanup timing (days of continuous unreachability)
|
|
||||||
CLEANUP_WARNING_DAYS = 83
|
|
||||||
CLEANUP_DELETE_DAYS = 90
|
|
||||||
|
|
||||||
# Counter thresholds derived from days (60-second polling intervals)
|
|
||||||
CLEANUP_WARNING_THRESHOLD = CLEANUP_WARNING_DAYS * 24 * 60
|
|
||||||
CLEANUP_DELETE_THRESHOLD = CLEANUP_DELETE_DAYS * 24 * 60
|
|
||||||
|
|
||||||
# Failure counter threshold for treating stream status as "unknown"
|
|
||||||
UNKNOWN_STATUS_THRESHOLD = 15
|
|
||||||
|
|
||||||
# Maximum field lengths based on Owncast's configuration
|
|
||||||
# Source: https://github.com/owncast/owncast/blob/master/
|
|
||||||
# web/utils/config-constants.tsx
|
|
||||||
MAX_INSTANCE_TITLE_LENGTH = 255 # Server Name (line 81)
|
|
||||||
MAX_STREAM_TITLE_LENGTH = 100 # Stream Title (line 91)
|
|
||||||
MAX_TAG_LENGTH = 24 # Per tag (line 208)
|
|
||||||
|
|
||||||
|
|
||||||
def should_query_stream(failure_counter: int) -> bool:
|
|
||||||
"""Determine if a stream should be queried based on failure count.
|
|
||||||
|
|
||||||
Implements progressive backoff with increasing intervals:
|
|
||||||
- Counters 0-4: every 60s (first 5 minutes)
|
|
||||||
- Counters 5-9: every 2min (next 5 minutes)
|
|
||||||
- Counters 10-14: every 3min (next 5 minutes)
|
|
||||||
- Counters 15-29: every 5min (next 15 minutes)
|
|
||||||
- Counters 30+: every 15min
|
|
||||||
|
|
||||||
:param failure_counter: The current failure counter value.
|
|
||||||
:return: True if the stream should be queried this cycle.
|
|
||||||
"""
|
|
||||||
if failure_counter <= 4:
|
|
||||||
# Query every cycle for first 5 minutes (counters 0-4)
|
|
||||||
return True
|
|
||||||
if failure_counter <= 9:
|
|
||||||
# Query every 2nd cycle for next 5 minutes (counters 5-9)
|
|
||||||
return failure_counter % 2 == 0
|
|
||||||
if failure_counter <= 14:
|
|
||||||
# Query every 3rd cycle for next 5 minutes (counters 10-14)
|
|
||||||
return failure_counter % 3 == 0
|
|
||||||
if failure_counter <= 29:
|
|
||||||
# Query every 5th cycle for next 15 minutes (counters 15-29)
|
|
||||||
return failure_counter % 5 == 0
|
|
||||||
# Query every 15th cycle after 30 minutes (counter 30+)
|
|
||||||
return failure_counter % 15 == 0
|
|
||||||
|
|
||||||
|
|
||||||
def domainify(url: str) -> str:
|
|
||||||
"""Extract and sanitize a domain from user input.
|
|
||||||
|
|
||||||
Handles URLs, bare domains, and email-style input (user@domain).
|
|
||||||
Only allows valid domain characters (alphanumeric, hyphens, periods).
|
|
||||||
|
|
||||||
:param url: URL, domain, or email-style string
|
|
||||||
:return: Sanitized domain
|
|
||||||
"""
|
|
||||||
# Handle email-style format first (e.g., "notify@stream.logal.dev")
|
|
||||||
if "@" in url:
|
|
||||||
url = url.split("@")[-1]
|
|
||||||
|
|
||||||
# Prepend // if no scheme so urlparse treats input as netloc
|
|
||||||
if not url.startswith(("http://", "https://", "//")):
|
|
||||||
url = f"//{url}"
|
|
||||||
|
|
||||||
parsed = urlparse(url)
|
|
||||||
domain = (parsed.netloc or parsed.path).lower()
|
|
||||||
|
|
||||||
# Strip port and path
|
|
||||||
domain = domain.split(":")[0].split("/")[0]
|
|
||||||
|
|
||||||
# Allow only valid domain characters
|
|
||||||
return re.sub(r"[^a-z0-9.-]", "", domain).strip(".-")
|
|
||||||
|
|
||||||
|
|
||||||
def truncate(text: str, max_length: int) -> str:
|
|
||||||
"""Truncate text to a maximum length.
|
|
||||||
|
|
||||||
:param text: The text to truncate
|
|
||||||
:param max_length: Maximum allowed length
|
|
||||||
:return: Truncated text, or original if within limit
|
|
||||||
"""
|
|
||||||
if not text or len(text) <= max_length:
|
|
||||||
return text
|
|
||||||
return text[:max_length]
|
|
||||||
|
|
||||||
|
|
||||||
_MARKDOWN_ESCAPE_TABLE = str.maketrans({c: f"\\{c}" for c in r"\*_[]()~`#+-=|{}.!<>&"})
|
|
||||||
|
|
||||||
|
|
||||||
def escape_markdown(text: str) -> str:
|
|
||||||
"""Escape Markdown special characters to prevent injection attacks.
|
|
||||||
|
|
||||||
This function sanitizes untrusted external input (like stream names and titles)
|
|
||||||
before embedding them in Markdown-formatted messages. It prevents malicious
|
|
||||||
actors from injecting arbitrary Markdown/HTML content.
|
|
||||||
|
|
||||||
:param text: The text to escape
|
|
||||||
:return: The escaped text safe for Markdown rendering
|
|
||||||
"""
|
|
||||||
if not text:
|
|
||||||
return text
|
|
||||||
|
|
||||||
return text.translate(_MARKDOWN_ESCAPE_TABLE)
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_for_plain_text(text: str) -> str:
|
|
||||||
"""Sanitize text for plain text rendering.
|
|
||||||
|
|
||||||
Remove newlines and normalize whitespace without escaping
|
|
||||||
special characters. Use this for plain text notifications where
|
|
||||||
escaping would show literal backslashes.
|
|
||||||
|
|
||||||
:param text: The text to sanitize
|
|
||||||
:return: Sanitized text
|
|
||||||
"""
|
|
||||||
if not text:
|
|
||||||
return text
|
|
||||||
|
|
||||||
# Remove newlines and carriage returns to prevent multi-line injection
|
|
||||||
sanitized = text.replace("\n", " ").replace("\r", " ")
|
|
||||||
|
|
||||||
# Collapse multiple spaces into single space
|
|
||||||
return " ".join(sanitized.split())
|
|
||||||
|
|
||||||
|
|
||||||
def sanitize_for_markdown(text: str) -> str:
|
|
||||||
"""Sanitize text for safe Markdown rendering.
|
|
||||||
|
|
||||||
Remove newlines, normalize whitespace, and escape Markdown special
|
|
||||||
characters. Use this for any untrusted external content before
|
|
||||||
embedding in Markdown messages.
|
|
||||||
|
|
||||||
Note: This function does not truncate. Size limits should be
|
|
||||||
enforced at the model layer (e.g., in from_api_response methods).
|
|
||||||
|
|
||||||
:param text: The text to sanitize
|
|
||||||
:return: Sanitized and escaped text safe for Markdown rendering
|
|
||||||
"""
|
|
||||||
if not text:
|
|
||||||
return text
|
|
||||||
|
|
||||||
return escape_markdown(sanitize_for_plain_text(text))
|
|
||||||
+25
-50
@@ -22,12 +22,12 @@ dev = [
|
|||||||
"aioresponses>=0.7.8",
|
"aioresponses>=0.7.8",
|
||||||
"codespell>=2.4.2",
|
"codespell>=2.4.2",
|
||||||
"hatch>=1.16.5",
|
"hatch>=1.16.5",
|
||||||
"mypy>=1.20.2",
|
"mypy>=2.1.0",
|
||||||
"pip-audit>=2.10.0",
|
"pip-audit>=2.10.0",
|
||||||
"pytest>=9.0.3",
|
"pytest>=9.0.3",
|
||||||
"pytest-asyncio>=1.3.0",
|
"pytest-asyncio>=1.3.0",
|
||||||
"pytest-cov>=7.1.0",
|
"pytest-cov>=7.1.0",
|
||||||
"ruff>=0.15.12",
|
"ruff>=0.15.13",
|
||||||
"time-machine>=3.2.0",
|
"time-machine>=3.2.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -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 = [
|
||||||
|
"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)
|
"D203", # incompatible with D211 (no blank line before class docstring)
|
||||||
"D213", # incompatible with D212 (summary on first line)
|
"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"
|
||||||
|
|||||||
+18
-11
@@ -23,15 +23,22 @@ from prometheus_client import generate_latest
|
|||||||
|
|
||||||
from owncastsentry import OwncastSentry
|
from owncastsentry import OwncastSentry
|
||||||
from owncastsentry.config import Config
|
from owncastsentry.config import Config
|
||||||
from owncastsentry.database import StreamRepository, SubscriptionRepository
|
from owncastsentry.repository import (
|
||||||
from owncastsentry.migrations import get_upgrade_table
|
StreamRepository,
|
||||||
|
SubscriptionRepository,
|
||||||
|
get_upgrade_table,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
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.models import StreamConfig, StreamState
|
from owncastsentry.types import StreamConfigObservation, StreamStateObservation
|
||||||
|
|
||||||
|
|
||||||
def generate_metrics_output(metrics: MetricsService) -> str:
|
def generate_metrics_output(metrics: MetricsService) -> str:
|
||||||
@@ -69,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()
|
||||||
|
|
||||||
@@ -205,19 +212,19 @@ class _StubMatrixClient:
|
|||||||
class _StubOwncastClient:
|
class _StubOwncastClient:
|
||||||
"""Configurable stub for the Owncast HTTP client."""
|
"""Configurable stub for the Owncast HTTP client."""
|
||||||
|
|
||||||
stream_state: StreamState | None = None
|
stream_state: StreamStateObservation | None = None
|
||||||
stream_config: StreamConfig | None = None
|
stream_config: StreamConfigObservation | None = None
|
||||||
state_call_count: int = field(default=0, init=False)
|
state_call_count: int = field(default=0, init=False)
|
||||||
config_call_count: int = field(default=0, init=False)
|
config_call_count: int = field(default=0, init=False)
|
||||||
queried_domains: list[str] = field(default_factory=list, init=False)
|
queried_domains: list[str] = field(default_factory=list, init=False)
|
||||||
|
|
||||||
async def get_stream_state(self, domain: str) -> StreamState | None:
|
async def get_stream_state(self, domain: str) -> StreamStateObservation | None:
|
||||||
"""Return the configured stream state."""
|
"""Return the configured stream observation."""
|
||||||
self.state_call_count += 1
|
self.state_call_count += 1
|
||||||
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) -> StreamConfigObservation | 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
|
||||||
|
|||||||
+176
-101
@@ -15,28 +15,66 @@
|
|||||||
"""Tests for bot command handlers."""
|
"""Tests for bot command handlers."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from unittest.mock import MagicMock
|
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 CommandHandler
|
from owncastsentry.commands import (
|
||||||
from owncastsentry.models import StreamState
|
_SECONDS_PER_DAY,
|
||||||
from owncastsentry.utils import OWNCAST_STATUS_PATH, UNKNOWN_STATUS_THRESHOLD
|
_SECONDS_PER_HOUR,
|
||||||
|
_SECONDS_PER_MINUTE,
|
||||||
|
_escape_markdown,
|
||||||
|
_format_duration,
|
||||||
|
_sanitize_for_markdown,
|
||||||
|
)
|
||||||
|
from owncastsentry.owncast_client import _OWNCAST_STATUS_PATH
|
||||||
|
from owncastsentry.types import UNKNOWN_STATUS_THRESHOLD
|
||||||
from tests.conftest import VALID_STATUS_RESPONSE
|
from tests.conftest import VALID_STATUS_RESPONSE
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from maubot.testing.bot import TestBot
|
||||||
|
|
||||||
def _make_command_handler() -> CommandHandler:
|
from owncastsentry import OwncastSentry
|
||||||
"""Build a CommandHandler with dummy dependencies for pure logic tests."""
|
|
||||||
return CommandHandler(
|
|
||||||
owncast_client=MagicMock(),
|
class TestEscapeMarkdown:
|
||||||
stream_repo=MagicMock(),
|
"""Markdown special character escaping."""
|
||||||
subscription_repo=MagicMock(),
|
|
||||||
logger=logging.getLogger("test"),
|
@pytest.mark.parametrize(
|
||||||
|
("input_text", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param("hello", "hello", id="plain-text-unchanged"),
|
||||||
|
pytest.param("*bold*", "\\*bold\\*", id="asterisks"),
|
||||||
|
pytest.param("_italic_", "\\_italic\\_", id="underscores"),
|
||||||
|
pytest.param("[link](url)", "\\[link\\]\\(url\\)", id="link-syntax"),
|
||||||
|
pytest.param("`code`", "\\`code\\`", id="backticks"),
|
||||||
|
pytest.param("# heading", "\\# heading", id="heading"),
|
||||||
|
pytest.param("> quote", "\\> quote", id="blockquote"),
|
||||||
|
pytest.param("<html>", "\\<html\\>", id="angle-brackets"),
|
||||||
|
pytest.param("a & b", "a \\& b", id="ampersand"),
|
||||||
|
pytest.param("a\\b", "a\\\\b", id="backslash"),
|
||||||
|
pytest.param("", "", id="empty-string"),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
def test_escapes_special_chars(self, input_text: str, expected: str) -> None:
|
||||||
|
"""Escape Markdown special characters in input text."""
|
||||||
|
assert _escape_markdown(input_text) == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestSanitizeForMarkdown:
|
||||||
|
"""Markdown sanitization combining whitespace normalization and escaping."""
|
||||||
|
|
||||||
|
def test_removes_newlines_and_escapes(self) -> None:
|
||||||
|
"""Normalize newlines and escape Markdown special characters."""
|
||||||
|
result = _sanitize_for_markdown("*bold*\nnew line")
|
||||||
|
assert result == "\\*bold\\* new line"
|
||||||
|
|
||||||
|
def test_empty_string(self) -> None:
|
||||||
|
"""Return empty string unchanged."""
|
||||||
|
assert _sanitize_for_markdown("") == ""
|
||||||
|
|
||||||
|
|
||||||
class TestFormatDuration:
|
class TestFormatDuration:
|
||||||
@@ -49,34 +87,41 @@ 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"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@time_machine.travel(_NOW)
|
|
||||||
def test_formats_duration(self, seconds_ago: int, expected: str) -> None:
|
def test_formats_duration(self, seconds_ago: int, expected: str) -> None:
|
||||||
"""Format a timestamp into a human-readable duration."""
|
"""Format a timestamp into a human-readable duration."""
|
||||||
handler = _make_command_handler()
|
|
||||||
timestamp = (self._NOW - timedelta(seconds=seconds_ago)).isoformat()
|
timestamp = (self._NOW - timedelta(seconds=seconds_ago)).isoformat()
|
||||||
result = handler._format_duration(timestamp)
|
result = _format_duration(timestamp, self._NOW)
|
||||||
assert result == expected
|
assert result == expected
|
||||||
|
|
||||||
def test_invalid_timestamp(self) -> None:
|
def test_invalid_timestamp(self) -> None:
|
||||||
"""Return 'unknown duration' for unparsable timestamps."""
|
"""Return 'unknown duration' for unparsable timestamps."""
|
||||||
handler = _make_command_handler()
|
assert _format_duration("not-a-timestamp", self._NOW) == "unknown duration"
|
||||||
assert handler._format_duration("not-a-timestamp") == "unknown duration"
|
|
||||||
|
def test_naive_timestamp(self) -> None:
|
||||||
|
"""Return 'unknown duration' for timestamps without timezone information."""
|
||||||
|
assert _format_duration("2026-03-13T11:59:00", self._NOW) == "unknown duration"
|
||||||
|
|
||||||
|
def test_future_timestamp(self) -> None:
|
||||||
|
"""Return 'unknown duration' for timestamps in the future."""
|
||||||
|
timestamp = (self._NOW + timedelta(seconds=1)).isoformat()
|
||||||
|
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:
|
||||||
mocked.get(
|
mocked.get(
|
||||||
status_url,
|
status_url,
|
||||||
@@ -90,11 +135,9 @@ 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:
|
||||||
mocked.get(status_url, status=404)
|
mocked.get(status_url, status=404)
|
||||||
await maubot_test_bot.send("!subscribe invalid.com")
|
await maubot_test_bot.send("!subscribe invalid.com")
|
||||||
@@ -107,11 +150,9 @@ 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:
|
||||||
mocked.get(
|
mocked.get(
|
||||||
status_url,
|
status_url,
|
||||||
@@ -128,10 +169,10 @@ 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 validation when the domain already has subscriptions."""
|
||||||
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:
|
||||||
mocked.get(
|
mocked.get(
|
||||||
status_url,
|
status_url,
|
||||||
@@ -139,9 +180,9 @@ class TestSubscribeCommand:
|
|||||||
)
|
)
|
||||||
await maubot_test_bot.send("!subscribe stream.logal.dev")
|
await maubot_test_bot.send("!subscribe stream.logal.dev")
|
||||||
|
|
||||||
# Subscribe from a different room; skips validation and should not
|
# Subscribe from a different room. The existing subscribed domain skips
|
||||||
# query the remote instance. An empty aioresponses context will raise
|
# validation, so an empty aioresponses context will raise ConnectionError
|
||||||
# ConnectionError if any HTTP request is attempted.
|
# if any HTTP request is attempted.
|
||||||
with aioresponses():
|
with aioresponses():
|
||||||
await maubot_test_bot.send(
|
await maubot_test_bot.send(
|
||||||
"!subscribe stream.logal.dev", room_id="!otherroom:example.com"
|
"!subscribe stream.logal.dev", room_id="!otherroom:example.com"
|
||||||
@@ -154,12 +195,13 @@ 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:
|
||||||
mocked.get(
|
mocked.get(
|
||||||
status_url,
|
status_url,
|
||||||
@@ -175,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")
|
||||||
|
|
||||||
@@ -187,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")
|
||||||
|
|
||||||
@@ -202,10 +243,12 @@ 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}"
|
||||||
with aioresponses() as mocked:
|
with aioresponses() as mocked:
|
||||||
mocked.get(
|
mocked.get(
|
||||||
status_url,
|
status_url,
|
||||||
@@ -215,12 +258,11 @@ class TestSubscriptionsCommand:
|
|||||||
|
|
||||||
# Update stream state to be online with a name
|
# Update stream state to be online with a name
|
||||||
await maubot_plugin.stream_repo.update(
|
await maubot_plugin.stream_repo.update(
|
||||||
StreamState(
|
"stream.logal.dev",
|
||||||
domain="stream.logal.dev",
|
|
||||||
name="Test Stream",
|
name="Test Stream",
|
||||||
title="Playing Games",
|
title="Playing Games",
|
||||||
last_connect_time="2026-01-01T12:00:00Z",
|
online=True,
|
||||||
)
|
status_since="2026-01-01T12:00:00+00:00",
|
||||||
)
|
)
|
||||||
|
|
||||||
await maubot_test_bot.send("!subscriptions")
|
await maubot_test_bot.send("!subscriptions")
|
||||||
@@ -237,10 +279,46 @@ 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_offline_stream(self, maubot_test_bot, maubot_plugin) -> None:
|
async def test_escapes_markdown_in_stream_name_and_title(
|
||||||
|
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}"
|
||||||
|
with aioresponses() as mocked:
|
||||||
|
mocked.get(
|
||||||
|
status_url,
|
||||||
|
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||||
|
)
|
||||||
|
await maubot_test_bot.send("!subscribe stream.logal.dev")
|
||||||
|
|
||||||
|
await maubot_plugin.stream_repo.update(
|
||||||
|
"stream.logal.dev",
|
||||||
|
name="*Bold* [link](https://evil.example)\nName",
|
||||||
|
title="`code` > quote #tag",
|
||||||
|
online=True,
|
||||||
|
status_since="2026-01-01T12:00:00+00:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
await maubot_test_bot.send("!subscriptions")
|
||||||
|
|
||||||
|
content = maubot_test_bot.responded[1].content
|
||||||
|
assert "● ***Bold* [link](https://evil.example) Name**" in content.body
|
||||||
|
assert " ○ Title: `code` > quote #tag" in content.body
|
||||||
|
assert content.formatted_body is not None
|
||||||
|
assert '<a href="https://evil.example">' not in content.formatted_body
|
||||||
|
assert (
|
||||||
|
"<strong>*Bold* [link](https://evil.example) Name</strong>"
|
||||||
|
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))
|
||||||
|
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}"
|
||||||
with aioresponses() as mocked:
|
with aioresponses() as mocked:
|
||||||
mocked.get(
|
mocked.get(
|
||||||
status_url,
|
status_url,
|
||||||
@@ -250,11 +328,9 @@ class TestSubscriptionsCommand:
|
|||||||
|
|
||||||
# Update stream state to be offline
|
# Update stream state to be offline
|
||||||
await maubot_plugin.stream_repo.update(
|
await maubot_plugin.stream_repo.update(
|
||||||
StreamState(
|
"stream.logal.dev",
|
||||||
domain="stream.logal.dev",
|
|
||||||
name="Test Stream",
|
name="Test Stream",
|
||||||
last_disconnect_time="2026-01-01T10:00:00Z",
|
status_since="2026-01-01T10:00:00+00:00",
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await maubot_test_bot.send("!subscriptions")
|
await maubot_test_bot.send("!subscriptions")
|
||||||
@@ -270,10 +346,10 @@ 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}"
|
||||||
with aioresponses() as mocked:
|
with aioresponses() as mocked:
|
||||||
mocked.get(
|
mocked.get(
|
||||||
status_url,
|
status_url,
|
||||||
@@ -294,9 +370,11 @@ 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:
|
||||||
mocked.get(
|
mocked.get(
|
||||||
status_url,
|
status_url,
|
||||||
@@ -324,17 +402,17 @@ 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 ordered by domain with mixed statuses."""
|
||||||
# Subscribe in reverse alphabetical order to verify sorted output
|
# Subscribe in reverse domain order to verify domain-sorted output
|
||||||
with aioresponses() as mocked:
|
with aioresponses() as mocked:
|
||||||
mocked.get(
|
mocked.get(
|
||||||
f"https://beta.com{OWNCAST_STATUS_PATH}",
|
f"https://beta.com{_OWNCAST_STATUS_PATH}",
|
||||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||||
)
|
)
|
||||||
mocked.get(
|
mocked.get(
|
||||||
f"https://alpha.com{OWNCAST_STATUS_PATH}",
|
f"https://alpha.com{_OWNCAST_STATUS_PATH}",
|
||||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||||
)
|
)
|
||||||
await maubot_test_bot.send("!subscribe beta.com")
|
await maubot_test_bot.send("!subscribe beta.com")
|
||||||
@@ -342,19 +420,16 @@ class TestSubscriptionsCommand:
|
|||||||
|
|
||||||
# Set alpha online, beta offline
|
# Set alpha online, beta offline
|
||||||
await maubot_plugin.stream_repo.update(
|
await maubot_plugin.stream_repo.update(
|
||||||
StreamState(
|
"alpha.com",
|
||||||
domain="alpha.com",
|
|
||||||
name="Alpha Stream",
|
name="Alpha Stream",
|
||||||
title="Streaming Live",
|
title="Streaming Live",
|
||||||
last_connect_time="2026-03-13T10:00:00Z",
|
online=True,
|
||||||
)
|
status_since="2026-03-13T10:00:00+00:00",
|
||||||
)
|
)
|
||||||
await maubot_plugin.stream_repo.update(
|
await maubot_plugin.stream_repo.update(
|
||||||
StreamState(
|
"beta.com",
|
||||||
domain="beta.com",
|
|
||||||
name="Beta Stream",
|
name="Beta Stream",
|
||||||
last_disconnect_time="2026-03-12T18:00:00Z",
|
status_since="2026-03-12T18:00:00+00:00",
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await maubot_test_bot.send("!subscriptions")
|
await maubot_test_bot.send("!subscriptions")
|
||||||
@@ -376,10 +451,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")
|
||||||
|
|
||||||
@@ -390,10 +466,12 @@ 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}"
|
||||||
with aioresponses() as mocked:
|
with aioresponses() as mocked:
|
||||||
mocked.get(
|
mocked.get(
|
||||||
status_url,
|
status_url,
|
||||||
@@ -403,11 +481,9 @@ class TestLiveCommand:
|
|||||||
|
|
||||||
# Update stream state to offline
|
# Update stream state to offline
|
||||||
await maubot_plugin.stream_repo.update(
|
await maubot_plugin.stream_repo.update(
|
||||||
StreamState(
|
"stream.logal.dev",
|
||||||
domain="stream.logal.dev",
|
|
||||||
name="Test Stream",
|
name="Test Stream",
|
||||||
last_disconnect_time="2026-01-01T10:00:00Z",
|
status_since="2026-01-01T10:00:00+00:00",
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
await maubot_test_bot.send("!live")
|
await maubot_test_bot.send("!live")
|
||||||
@@ -420,10 +496,12 @@ 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}"
|
||||||
with aioresponses() as mocked:
|
with aioresponses() as mocked:
|
||||||
mocked.get(
|
mocked.get(
|
||||||
status_url,
|
status_url,
|
||||||
@@ -433,12 +511,11 @@ class TestLiveCommand:
|
|||||||
|
|
||||||
# Update stream state to online
|
# Update stream state to online
|
||||||
await maubot_plugin.stream_repo.update(
|
await maubot_plugin.stream_repo.update(
|
||||||
StreamState(
|
"stream.logal.dev",
|
||||||
domain="stream.logal.dev",
|
|
||||||
name="Test Stream",
|
name="Test Stream",
|
||||||
title="Playing Games",
|
title="Playing Games",
|
||||||
last_connect_time="2026-01-01T12:00:00Z",
|
online=True,
|
||||||
)
|
status_since="2026-01-01T12:00:00+00:00",
|
||||||
)
|
)
|
||||||
|
|
||||||
await maubot_test_bot.send("!live")
|
await maubot_test_bot.send("!live")
|
||||||
@@ -454,38 +531,36 @@ 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 ordered by domain with different durations."""
|
||||||
# Subscribe in reverse alphabetical order to verify sorted output
|
# Subscribe in reverse domain order to verify domain-sorted output
|
||||||
with aioresponses() as mocked:
|
with aioresponses() as mocked:
|
||||||
mocked.get(
|
mocked.get(
|
||||||
f"https://beta.com{OWNCAST_STATUS_PATH}",
|
f"https://beta.com{_OWNCAST_STATUS_PATH}",
|
||||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||||
)
|
)
|
||||||
mocked.get(
|
mocked.get(
|
||||||
f"https://alpha.com{OWNCAST_STATUS_PATH}",
|
f"https://alpha.com{_OWNCAST_STATUS_PATH}",
|
||||||
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
body=json.dumps(VALID_STATUS_RESPONSE).encode(),
|
||||||
)
|
)
|
||||||
await maubot_test_bot.send("!subscribe beta.com")
|
await maubot_test_bot.send("!subscribe beta.com")
|
||||||
await maubot_test_bot.send("!subscribe alpha.com")
|
await maubot_test_bot.send("!subscribe alpha.com")
|
||||||
|
|
||||||
# Set both streams online with different connect times
|
# Set both streams online with different status timestamps
|
||||||
await maubot_plugin.stream_repo.update(
|
await maubot_plugin.stream_repo.update(
|
||||||
StreamState(
|
"alpha.com",
|
||||||
domain="alpha.com",
|
|
||||||
name="Alpha Stream",
|
name="Alpha Stream",
|
||||||
title="Morning Show",
|
title="Morning Show",
|
||||||
last_connect_time="2026-03-13T10:00:00Z",
|
online=True,
|
||||||
)
|
status_since="2026-03-13T10:00:00+00:00",
|
||||||
)
|
)
|
||||||
await maubot_plugin.stream_repo.update(
|
await maubot_plugin.stream_repo.update(
|
||||||
StreamState(
|
"beta.com",
|
||||||
domain="beta.com",
|
|
||||||
name="Beta Stream",
|
name="Beta Stream",
|
||||||
title="Evening Vibes",
|
title="Evening Vibes",
|
||||||
last_connect_time="2026-03-13T06:00:00Z",
|
online=True,
|
||||||
)
|
status_since="2026-03-13T06:00:00+00:00",
|
||||||
)
|
)
|
||||||
|
|
||||||
await maubot_test_bot.send("!live")
|
await maubot_test_bot.send("!live")
|
||||||
|
|||||||
@@ -1,148 +0,0 @@
|
|||||||
# Copyright 2026 Logan Fick
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
|
|
||||||
"""Tests for database repository classes."""
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from owncastsentry.database import StreamRepository, SubscriptionRepository
|
|
||||||
|
|
||||||
|
|
||||||
class TestStreamExists:
|
|
||||||
"""Stream existence checks."""
|
|
||||||
|
|
||||||
async def test_returns_true_for_existing_stream(
|
|
||||||
self, stream_repo: StreamRepository
|
|
||||||
) -> None:
|
|
||||||
"""Return True when the stream exists in the database."""
|
|
||||||
await stream_repo.create("example.com")
|
|
||||||
assert await stream_repo.exists("example.com") is True
|
|
||||||
|
|
||||||
async def test_returns_false_for_missing_stream(
|
|
||||||
self, stream_repo: StreamRepository
|
|
||||||
) -> None:
|
|
||||||
"""Return False when the stream does not exist in the database."""
|
|
||||||
assert await stream_repo.exists("missing.com") is False
|
|
||||||
|
|
||||||
|
|
||||||
class TestStreamDelete:
|
|
||||||
"""Stream record deletion."""
|
|
||||||
|
|
||||||
async def test_removes_stream_record(self, stream_repo: StreamRepository) -> None:
|
|
||||||
"""Remove the stream record so get_by_domain returns None."""
|
|
||||||
await stream_repo.create("example.com")
|
|
||||||
await stream_repo.delete("example.com")
|
|
||||||
assert await stream_repo.get_by_domain("example.com") is None
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetSubscribedStreamsForRoom:
|
|
||||||
"""Subscribed stream lookup by room."""
|
|
||||||
|
|
||||||
async def test_returns_all_domains_for_room(
|
|
||||||
self,
|
|
||||||
stream_repo: StreamRepository,
|
|
||||||
subscription_repo: SubscriptionRepository,
|
|
||||||
) -> None:
|
|
||||||
"""Return all domains a room is subscribed to."""
|
|
||||||
await stream_repo.create("alpha.com")
|
|
||||||
await stream_repo.create("beta.com")
|
|
||||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
|
||||||
await subscription_repo.add("beta.com", "!room1:example.com")
|
|
||||||
|
|
||||||
result = await subscription_repo.get_subscribed_streams_for_room(
|
|
||||||
"!room1:example.com"
|
|
||||||
)
|
|
||||||
assert sorted(result) == ["alpha.com", "beta.com"]
|
|
||||||
|
|
||||||
async def test_returns_empty_list_for_unsubscribed_room(
|
|
||||||
self, subscription_repo: SubscriptionRepository
|
|
||||||
) -> None:
|
|
||||||
"""Return an empty list when the room has no subscriptions."""
|
|
||||||
result = await subscription_repo.get_subscribed_streams_for_room(
|
|
||||||
"!nobody:example.com"
|
|
||||||
)
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
|
|
||||||
class TestGetAllSubscribedDomains:
|
|
||||||
"""Unique subscribed domain retrieval."""
|
|
||||||
|
|
||||||
async def test_returns_each_domain_once(
|
|
||||||
self,
|
|
||||||
stream_repo: StreamRepository,
|
|
||||||
subscription_repo: SubscriptionRepository,
|
|
||||||
) -> None:
|
|
||||||
"""Return each domain once even with multiple subscriptions."""
|
|
||||||
await stream_repo.create("alpha.com")
|
|
||||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
|
||||||
await subscription_repo.add("alpha.com", "!room2:example.com")
|
|
||||||
|
|
||||||
result = await subscription_repo.get_all_subscribed_domains()
|
|
||||||
assert result == ["alpha.com"]
|
|
||||||
|
|
||||||
async def test_returns_empty_list_with_no_subscriptions(
|
|
||||||
self, subscription_repo: SubscriptionRepository
|
|
||||||
) -> None:
|
|
||||||
"""Return an empty list when there are no subscriptions."""
|
|
||||||
result = await subscription_repo.get_all_subscribed_domains()
|
|
||||||
assert result == []
|
|
||||||
|
|
||||||
|
|
||||||
class TestCountByDomain:
|
|
||||||
"""Subscription count by domain."""
|
|
||||||
|
|
||||||
async def test_returns_correct_count(
|
|
||||||
self,
|
|
||||||
stream_repo: StreamRepository,
|
|
||||||
subscription_repo: SubscriptionRepository,
|
|
||||||
) -> None:
|
|
||||||
"""Return the correct subscription count for a domain."""
|
|
||||||
await stream_repo.create("alpha.com")
|
|
||||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
|
||||||
await subscription_repo.add("alpha.com", "!room2:example.com")
|
|
||||||
|
|
||||||
assert await subscription_repo.count_by_domain("alpha.com") == 2
|
|
||||||
|
|
||||||
async def test_returns_zero_for_unknown_domain(
|
|
||||||
self, subscription_repo: SubscriptionRepository
|
|
||||||
) -> None:
|
|
||||||
"""Return 0 for a domain with no subscriptions."""
|
|
||||||
assert await subscription_repo.count_by_domain("unknown.com") == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestDeleteAllForDomain:
|
|
||||||
"""Bulk subscription deletion by domain."""
|
|
||||||
|
|
||||||
async def test_deletes_all_subscriptions_and_returns_count(
|
|
||||||
self,
|
|
||||||
stream_repo: StreamRepository,
|
|
||||||
subscription_repo: SubscriptionRepository,
|
|
||||||
) -> None:
|
|
||||||
"""Delete all subscriptions for the domain and return the count."""
|
|
||||||
await stream_repo.create("alpha.com")
|
|
||||||
await subscription_repo.add("alpha.com", "!room1:example.com")
|
|
||||||
await subscription_repo.add("alpha.com", "!room2:example.com")
|
|
||||||
|
|
||||||
deleted = await subscription_repo.delete_all_for_domain("alpha.com")
|
|
||||||
assert deleted == 2
|
|
||||||
|
|
||||||
rooms = await subscription_repo.get_subscribed_rooms("alpha.com")
|
|
||||||
assert rooms == []
|
|
||||||
|
|
||||||
async def test_returns_zero_for_unknown_domain(
|
|
||||||
self, subscription_repo: SubscriptionRepository
|
|
||||||
) -> None:
|
|
||||||
"""Return 0 when deleting subscriptions for an unknown domain."""
|
|
||||||
assert await subscription_repo.delete_all_for_domain("unknown.com") == 0
|
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from owncastsentry.metrics import ErrorSource, MetricsService, NotificationType
|
from owncastsentry.metrics import ErrorSource, MetricsService, NotificationType
|
||||||
from owncastsentry.models import StreamStatus
|
from owncastsentry.types import StreamStatus
|
||||||
from tests.conftest import generate_metrics_output
|
from tests.conftest import generate_metrics_output
|
||||||
|
|
||||||
|
|
||||||
@@ -142,7 +142,7 @@ class TestSetSubscriptionCount:
|
|||||||
|
|
||||||
|
|
||||||
class TestSetCheckFailures:
|
class TestSetCheckFailures:
|
||||||
"""Check failure counter gauge per domain."""
|
"""Consecutive check failure gauge per domain."""
|
||||||
|
|
||||||
def test_sets_count(self) -> None:
|
def test_sets_count(self) -> None:
|
||||||
"""Set the failure count for a domain."""
|
"""Set the failure count for a domain."""
|
||||||
@@ -196,7 +196,7 @@ class TestResponseTimer:
|
|||||||
assert len(matches) == 1
|
assert len(matches) == 1
|
||||||
|
|
||||||
def test_does_not_record_on_exception(self) -> None:
|
def test_does_not_record_on_exception(self) -> None:
|
||||||
"""Do not record when the block raises an exception."""
|
"""Do not record when the block raises before success() is called."""
|
||||||
service = MetricsService()
|
service = MetricsService()
|
||||||
with (
|
with (
|
||||||
pytest.raises(ValueError, match="boom"),
|
pytest.raises(ValueError, match="boom"),
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
# Copyright 2026 Logan Fick
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
|
|
||||||
"""Tests for data models."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from owncastsentry.models import StreamConfig, StreamState, StreamStatus
|
|
||||||
from owncastsentry.utils import (
|
|
||||||
MAX_INSTANCE_TITLE_LENGTH,
|
|
||||||
MAX_STREAM_TITLE_LENGTH,
|
|
||||||
MAX_TAG_LENGTH,
|
|
||||||
UNKNOWN_STATUS_THRESHOLD,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestStreamStateStatus:
|
|
||||||
"""Stream status derivation from state fields."""
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("failure_counter", "last_connect_time", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
UNKNOWN_STATUS_THRESHOLD + 1,
|
|
||||||
None,
|
|
||||||
StreamStatus.UNKNOWN,
|
|
||||||
id="above-threshold-offline-returns-unknown",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
UNKNOWN_STATUS_THRESHOLD + 1,
|
|
||||||
"2026-01-01T00:00:00Z",
|
|
||||||
StreamStatus.UNKNOWN,
|
|
||||||
id="above-threshold-online-returns-unknown",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
0,
|
|
||||||
"2026-01-01T00:00:00Z",
|
|
||||||
StreamStatus.ONLINE,
|
|
||||||
id="zero-failures-with-connect-time-returns-online",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
0,
|
|
||||||
None,
|
|
||||||
StreamStatus.OFFLINE,
|
|
||||||
id="zero-failures-no-connect-time-returns-offline",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
UNKNOWN_STATUS_THRESHOLD,
|
|
||||||
"2026-01-01T00:00:00Z",
|
|
||||||
StreamStatus.ONLINE,
|
|
||||||
id="at-threshold-with-connect-time-returns-online",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
UNKNOWN_STATUS_THRESHOLD,
|
|
||||||
None,
|
|
||||||
StreamStatus.OFFLINE,
|
|
||||||
id="at-threshold-no-connect-time-returns-offline",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_status(
|
|
||||||
self,
|
|
||||||
failure_counter: int,
|
|
||||||
last_connect_time: str | None,
|
|
||||||
expected: StreamStatus,
|
|
||||||
) -> None:
|
|
||||||
"""Return the correct status based on failure counter and connect time."""
|
|
||||||
state = StreamState(
|
|
||||||
domain="example.com",
|
|
||||||
failure_counter=failure_counter,
|
|
||||||
last_connect_time=last_connect_time,
|
|
||||||
)
|
|
||||||
assert state.status is expected
|
|
||||||
|
|
||||||
|
|
||||||
class TestStreamStateFromApiResponse:
|
|
||||||
"""StreamState construction from an API response dictionary."""
|
|
||||||
|
|
||||||
def test_typical_response(self) -> None:
|
|
||||||
"""Populate all fields from a complete API response."""
|
|
||||||
response = {
|
|
||||||
"streamTitle": "My Stream",
|
|
||||||
"lastConnectTime": "2026-01-01T00:00:00Z",
|
|
||||||
"lastDisconnectTime": "2025-12-31T23:00:00Z",
|
|
||||||
}
|
|
||||||
state = StreamState.from_api_response(response, "example.com")
|
|
||||||
assert state.domain == "example.com"
|
|
||||||
assert state.title == "My Stream"
|
|
||||||
assert state.last_connect_time == "2026-01-01T00:00:00Z"
|
|
||||||
assert state.last_disconnect_time == "2025-12-31T23:00:00Z"
|
|
||||||
assert state.name is None
|
|
||||||
assert state.failure_counter == 0
|
|
||||||
|
|
||||||
def test_empty_response_defaults(self) -> None:
|
|
||||||
"""Use defaults when optional fields are missing."""
|
|
||||||
state = StreamState.from_api_response({}, "bare.example.com")
|
|
||||||
assert state.domain == "bare.example.com"
|
|
||||||
assert state.title == ""
|
|
||||||
assert state.last_connect_time is None
|
|
||||||
assert state.last_disconnect_time is None
|
|
||||||
|
|
||||||
def test_title_truncation(self) -> None:
|
|
||||||
"""Truncate the stream title to MAX_STREAM_TITLE_LENGTH."""
|
|
||||||
long_title = "A" * (MAX_STREAM_TITLE_LENGTH + 50)
|
|
||||||
response = {"streamTitle": long_title}
|
|
||||||
state = StreamState.from_api_response(response, "example.com")
|
|
||||||
assert len(state.title) == MAX_STREAM_TITLE_LENGTH
|
|
||||||
assert state.title == "A" * MAX_STREAM_TITLE_LENGTH
|
|
||||||
|
|
||||||
|
|
||||||
class TestStreamStateFromDbRow:
|
|
||||||
"""StreamState construction from a database row dictionary."""
|
|
||||||
|
|
||||||
def test_typical_row(self) -> None:
|
|
||||||
"""Populate all fields from a complete database row."""
|
|
||||||
row = {
|
|
||||||
"domain": "example.com",
|
|
||||||
"name": "Test Instance",
|
|
||||||
"title": "Live Now",
|
|
||||||
"last_connect_time": "2026-01-01T00:00:00Z",
|
|
||||||
"last_disconnect_time": "2025-12-31T23:00:00Z",
|
|
||||||
"failure_counter": 3,
|
|
||||||
}
|
|
||||||
state = StreamState.from_db_row(row)
|
|
||||||
assert state.domain == "example.com"
|
|
||||||
assert state.name == "Test Instance"
|
|
||||||
assert state.title == "Live Now"
|
|
||||||
assert state.last_connect_time == "2026-01-01T00:00:00Z"
|
|
||||||
assert state.last_disconnect_time == "2025-12-31T23:00:00Z"
|
|
||||||
assert state.failure_counter == 3
|
|
||||||
|
|
||||||
def test_row_with_none_optional_fields(self) -> None:
|
|
||||||
"""Accept None for optional fields in a database row."""
|
|
||||||
row = {
|
|
||||||
"domain": "example.com",
|
|
||||||
"name": None,
|
|
||||||
"title": None,
|
|
||||||
"last_connect_time": None,
|
|
||||||
"last_disconnect_time": None,
|
|
||||||
"failure_counter": 0,
|
|
||||||
}
|
|
||||||
state = StreamState.from_db_row(row)
|
|
||||||
assert state.domain == "example.com"
|
|
||||||
assert state.name is None
|
|
||||||
assert state.title is None
|
|
||||||
assert state.last_connect_time is None
|
|
||||||
assert state.last_disconnect_time is None
|
|
||||||
assert state.failure_counter == 0
|
|
||||||
|
|
||||||
|
|
||||||
class TestStreamConfigFromApiResponse:
|
|
||||||
"""StreamConfig construction from an API response dictionary."""
|
|
||||||
|
|
||||||
def test_typical_response(self) -> None:
|
|
||||||
"""Populate name and tags from a complete API response."""
|
|
||||||
response = {"name": "My Instance", "tags": ["gaming", "music"]}
|
|
||||||
config = StreamConfig.from_api_response(response)
|
|
||||||
assert config.name == "My Instance"
|
|
||||||
assert config.tags == ["gaming", "music"]
|
|
||||||
|
|
||||||
def test_missing_keys_defaults(self) -> None:
|
|
||||||
"""Use defaults when name and tags keys are missing."""
|
|
||||||
config = StreamConfig.from_api_response({})
|
|
||||||
assert config.name == ""
|
|
||||||
assert config.tags == []
|
|
||||||
|
|
||||||
def test_name_truncation(self) -> None:
|
|
||||||
"""Truncate the instance name to MAX_INSTANCE_TITLE_LENGTH."""
|
|
||||||
long_name = "B" * (MAX_INSTANCE_TITLE_LENGTH + 50)
|
|
||||||
response = {"name": long_name, "tags": []}
|
|
||||||
config = StreamConfig.from_api_response(response)
|
|
||||||
assert len(config.name) == MAX_INSTANCE_TITLE_LENGTH
|
|
||||||
assert config.name == "B" * MAX_INSTANCE_TITLE_LENGTH
|
|
||||||
|
|
||||||
def test_tag_truncation(self) -> None:
|
|
||||||
"""Truncate each tag to MAX_TAG_LENGTH."""
|
|
||||||
long_tag = "C" * (MAX_TAG_LENGTH + 10)
|
|
||||||
response = {"name": "", "tags": [long_tag, "short"]}
|
|
||||||
config = StreamConfig.from_api_response(response)
|
|
||||||
assert len(config.tags[0]) == MAX_TAG_LENGTH
|
|
||||||
assert config.tags[0] == "C" * MAX_TAG_LENGTH
|
|
||||||
assert config.tags[1] == "short"
|
|
||||||
@@ -14,6 +14,7 @@
|
|||||||
|
|
||||||
"""Tests for the notification service."""
|
"""Tests for the notification service."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
@@ -21,12 +22,15 @@ from typing import TYPE_CHECKING
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from owncastsentry.metrics import MetricsService
|
from owncastsentry.metrics import MetricsService
|
||||||
from owncastsentry.notification_service import NotificationService
|
from owncastsentry.notification_service import (
|
||||||
from owncastsentry.utils import SECONDS_BETWEEN_NOTIFICATIONS
|
_SECONDS_BETWEEN_NOTIFICATIONS,
|
||||||
|
NotificationService,
|
||||||
|
_sanitize_for_plain_text,
|
||||||
|
)
|
||||||
from tests.conftest import _StubMatrixClient, generate_metrics_output
|
from tests.conftest import _StubMatrixClient, generate_metrics_output
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from owncastsentry.database import StreamRepository, SubscriptionRepository
|
from owncastsentry.repository import StreamRepository, SubscriptionRepository
|
||||||
|
|
||||||
|
|
||||||
def _make_service(
|
def _make_service(
|
||||||
@@ -44,6 +48,27 @@ def _make_service(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSanitizeForPlainText:
|
||||||
|
"""Plain text sanitization for notifications."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("input_text", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param("hello world", "hello world", id="plain-text"),
|
||||||
|
pytest.param("line1\nline2", "line1 line2", id="newline-removed"),
|
||||||
|
pytest.param("line1\rline2", "line1 line2", id="carriage-return"),
|
||||||
|
pytest.param("line1\r\nline2", "line1 line2", id="crlf-removed"),
|
||||||
|
pytest.param(
|
||||||
|
"too many spaces", "too many spaces", id="spaces-collapsed"
|
||||||
|
),
|
||||||
|
pytest.param("", "", id="empty-string"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_sanitizes(self, input_text: str, expected: str) -> None:
|
||||||
|
"""Sanitize the text for safe plain-text rendering."""
|
||||||
|
assert _sanitize_for_plain_text(input_text) == expected
|
||||||
|
|
||||||
|
|
||||||
class TestCanNotify:
|
class TestCanNotify:
|
||||||
"""Rate-limiting logic for notification cooldowns."""
|
"""Rate-limiting logic for notification cooldowns."""
|
||||||
|
|
||||||
@@ -75,7 +100,7 @@ class TestCanNotify:
|
|||||||
)
|
)
|
||||||
# Subtract an extra second to ensure the cooldown has fully elapsed
|
# Subtract an extra second to ensure the cooldown has fully elapsed
|
||||||
service.notification_timers_cache["example.com"] = (
|
service.notification_timers_cache["example.com"] = (
|
||||||
time.monotonic() - SECONDS_BETWEEN_NOTIFICATIONS - 1
|
time.monotonic() - _SECONDS_BETWEEN_NOTIFICATIONS - 1
|
||||||
)
|
)
|
||||||
assert service._can_notify("example.com") is True
|
assert service._can_notify("example.com") is True
|
||||||
|
|
||||||
@@ -103,6 +128,35 @@ class TestGetLastNotificationTime:
|
|||||||
assert service.get_last_notification_time("unknown.com") == 0
|
assert service.get_last_notification_time("unknown.com") == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestClearNotificationState:
|
||||||
|
"""Notification cache cleanup for deleted domains."""
|
||||||
|
|
||||||
|
def test_clears_cached_notification_time(
|
||||||
|
self, subscription_repo: SubscriptionRepository
|
||||||
|
) -> None:
|
||||||
|
"""Remove cached notification state for a domain."""
|
||||||
|
service = _make_service(
|
||||||
|
client=_StubMatrixClient(), subscription_repo=subscription_repo
|
||||||
|
)
|
||||||
|
service.notification_timers_cache["example.com"] = 12345.0
|
||||||
|
|
||||||
|
service.clear_notification_state("example.com")
|
||||||
|
|
||||||
|
assert service.get_last_notification_time("example.com") == 0
|
||||||
|
|
||||||
|
def test_missing_domain_is_ignored(
|
||||||
|
self, subscription_repo: SubscriptionRepository
|
||||||
|
) -> None:
|
||||||
|
"""Ignore cleanup for a domain with no cached state."""
|
||||||
|
service = _make_service(
|
||||||
|
client=_StubMatrixClient(), subscription_repo=subscription_repo
|
||||||
|
)
|
||||||
|
|
||||||
|
service.clear_notification_state("unknown.com")
|
||||||
|
|
||||||
|
assert service.get_last_notification_time("unknown.com") == 0
|
||||||
|
|
||||||
|
|
||||||
class TestFormatMessage:
|
class TestFormatMessage:
|
||||||
"""Notification message formatting."""
|
"""Notification message formatting."""
|
||||||
|
|
||||||
@@ -202,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,
|
||||||
@@ -210,12 +265,14 @@ 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
|
||||||
|
|
||||||
|
|
||||||
class TestNotifyStreamLive:
|
class TestNotifyStreamLive:
|
||||||
"""End-to-end notification sending."""
|
"""Live notification sending behavior."""
|
||||||
|
|
||||||
async def test_sends_to_all_subscribed_rooms(
|
async def test_sends_to_all_subscribed_rooms(
|
||||||
self,
|
self,
|
||||||
@@ -246,6 +303,74 @@ class TestNotifyStreamLive:
|
|||||||
for msg in client.sent_messages:
|
for msg in client.sent_messages:
|
||||||
assert msg.content.body == expected_body
|
assert msg.content.body == expected_body
|
||||||
|
|
||||||
|
async def test_records_cooldown_after_success(
|
||||||
|
self,
|
||||||
|
stream_repo: StreamRepository,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> None:
|
||||||
|
"""Record a cooldown after at least one room receives a notification."""
|
||||||
|
client = _StubMatrixClient()
|
||||||
|
service = _make_service(client=client, subscription_repo=subscription_repo)
|
||||||
|
|
||||||
|
await stream_repo.create("example.com")
|
||||||
|
await subscription_repo.add("example.com", "!room:matrix.org")
|
||||||
|
|
||||||
|
before_send = time.monotonic()
|
||||||
|
await service.notify_stream_live("example.com", "Stream", "Title", [])
|
||||||
|
|
||||||
|
assert service.get_last_notification_time("example.com") >= before_send
|
||||||
|
|
||||||
|
async def test_no_cooldown_when_no_subscribed_rooms(
|
||||||
|
self, subscription_repo: SubscriptionRepository
|
||||||
|
) -> None:
|
||||||
|
"""Do not record a cooldown if no room receives the notification."""
|
||||||
|
client = _StubMatrixClient()
|
||||||
|
service = _make_service(client=client, subscription_repo=subscription_repo)
|
||||||
|
|
||||||
|
await service.notify_stream_live("example.com", "Stream", "Title", [])
|
||||||
|
|
||||||
|
assert len(client.sent_messages) == 0
|
||||||
|
assert service.get_last_notification_time("example.com") == 0
|
||||||
|
|
||||||
|
async def test_no_cooldown_when_all_deliveries_fail(
|
||||||
|
self,
|
||||||
|
stream_repo: StreamRepository,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> None:
|
||||||
|
"""Do not record a cooldown if every room delivery fails."""
|
||||||
|
client = _StubMatrixClient()
|
||||||
|
client.should_fail_for_rooms.add("!bad:matrix.org")
|
||||||
|
service = _make_service(client=client, subscription_repo=subscription_repo)
|
||||||
|
|
||||||
|
await stream_repo.create("example.com")
|
||||||
|
await subscription_repo.add("example.com", "!bad:matrix.org")
|
||||||
|
|
||||||
|
await service.notify_stream_live("example.com", "Stream", "Title", [])
|
||||||
|
|
||||||
|
assert len(client.sent_messages) == 0
|
||||||
|
assert service.get_last_notification_time("example.com") == 0
|
||||||
|
|
||||||
|
async def test_counts_cancelled_delivery_as_failure(
|
||||||
|
self,
|
||||||
|
stream_repo: StreamRepository,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> None:
|
||||||
|
"""Count a cancelled delivery result as a failure."""
|
||||||
|
client = _StubMatrixClient()
|
||||||
|
|
||||||
|
async def send_message(_room_id: str, _content: object) -> None:
|
||||||
|
raise asyncio.CancelledError
|
||||||
|
|
||||||
|
client.send_message = send_message
|
||||||
|
service = _make_service(client=client, subscription_repo=subscription_repo)
|
||||||
|
|
||||||
|
await stream_repo.create("example.com")
|
||||||
|
await subscription_repo.add("example.com", "!room:matrix.org")
|
||||||
|
|
||||||
|
await service.notify_stream_live("example.com", "Stream", "Title", [])
|
||||||
|
|
||||||
|
assert service.get_last_notification_time("example.com") == 0
|
||||||
|
|
||||||
async def test_skips_when_rate_limited(
|
async def test_skips_when_rate_limited(
|
||||||
self,
|
self,
|
||||||
stream_repo: StreamRepository,
|
stream_repo: StreamRepository,
|
||||||
@@ -268,7 +393,7 @@ class TestNotifyStreamLive:
|
|||||||
stream_repo: StreamRepository,
|
stream_repo: StreamRepository,
|
||||||
subscription_repo: SubscriptionRepository,
|
subscription_repo: SubscriptionRepository,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Send to remaining rooms after a delivery failure."""
|
"""Deliver to other subscribed rooms when one room delivery fails."""
|
||||||
client = _StubMatrixClient()
|
client = _StubMatrixClient()
|
||||||
client.should_fail_for_rooms.add("!bad:matrix.org")
|
client.should_fail_for_rooms.add("!bad:matrix.org")
|
||||||
service = _make_service(client=client, subscription_repo=subscription_repo)
|
service = _make_service(client=client, subscription_repo=subscription_repo)
|
||||||
|
|||||||
+212
-13
@@ -16,13 +16,20 @@
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
from datetime import UTC, datetime
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import time_machine
|
||||||
from aioresponses import aioresponses
|
from aioresponses import aioresponses
|
||||||
|
|
||||||
from owncastsentry.metrics import MetricsService
|
from owncastsentry.metrics import MetricsService
|
||||||
from owncastsentry.owncast_client import OwncastClient
|
from owncastsentry.owncast_client import (
|
||||||
|
_MAX_JSON_RESPONSE_BYTES,
|
||||||
|
OwncastClient,
|
||||||
|
_read_limited_response_body,
|
||||||
|
_user_agent,
|
||||||
|
)
|
||||||
from tests.conftest import (
|
from tests.conftest import (
|
||||||
VALID_CONFIG_RESPONSE,
|
VALID_CONFIG_RESPONSE,
|
||||||
VALID_STATUS_RESPONSE,
|
VALID_STATUS_RESPONSE,
|
||||||
@@ -33,6 +40,32 @@ if TYPE_CHECKING:
|
|||||||
from collections.abc import AsyncIterator
|
from collections.abc import AsyncIterator
|
||||||
|
|
||||||
|
|
||||||
|
class _ChunkedContent:
|
||||||
|
"""Fake aiohttp response content that yields predefined chunks."""
|
||||||
|
|
||||||
|
def __init__(self, chunks: tuple[bytes, ...]) -> None:
|
||||||
|
"""Store chunks to return from iter_chunked."""
|
||||||
|
self._chunks = chunks
|
||||||
|
|
||||||
|
async def iter_chunked(self, _size: int) -> AsyncIterator[bytes]:
|
||||||
|
"""Yield chunks using the interface aiohttp exposes."""
|
||||||
|
for chunk in self._chunks:
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
|
||||||
|
class _ChunkedResponse:
|
||||||
|
"""Fake aiohttp response with chunked content."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
chunks: tuple[bytes, ...],
|
||||||
|
content_length: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Store the content stream and optional Content-Length value."""
|
||||||
|
self.content = _ChunkedContent(chunks)
|
||||||
|
self.content_length = content_length
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def owncast_client() -> AsyncIterator[OwncastClient]:
|
async def owncast_client() -> AsyncIterator[OwncastClient]:
|
||||||
"""Create an OwncastClient and close it after the test."""
|
"""Create an OwncastClient and close it after the test."""
|
||||||
@@ -45,13 +78,83 @@ async def owncast_client() -> AsyncIterator[OwncastClient]:
|
|||||||
await client.close()
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
class TestGetStreamState:
|
class TestUserAgent:
|
||||||
"""Stream state retrieval from the status API."""
|
"""User-Agent header construction."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("version", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
"1.2.3",
|
||||||
|
"OwncastSentry/1.2.3 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)",
|
||||||
|
id="semver",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"0.0.0",
|
||||||
|
"OwncastSentry/0.0.0 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)",
|
||||||
|
id="zeroed",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"1.1.1.dev10+gf0146d061.d20260313",
|
||||||
|
"OwncastSentry/1.1.1.dev10+gf0146d061.d20260313 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)",
|
||||||
|
id="dev-version",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_user_agent(self, version: str, expected: str) -> None:
|
||||||
|
"""Build a correctly formatted User-Agent header."""
|
||||||
|
assert _user_agent(version) == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestReadLimitedResponseBody:
|
||||||
|
"""Bounded response body reading."""
|
||||||
|
|
||||||
|
async def test_reads_all_chunks_before_returning(self) -> None:
|
||||||
|
"""Return the full body when JSON arrives in multiple chunks."""
|
||||||
|
response = _ChunkedResponse(
|
||||||
|
(
|
||||||
|
b'{"streamTitle":',
|
||||||
|
b'"hello","online":true}',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await _read_limited_response_body(response)
|
||||||
|
|
||||||
|
assert result == bytearray(b'{"streamTitle":"hello","online":true}')
|
||||||
|
|
||||||
|
async def test_returns_none_when_content_length_is_too_large(self) -> None:
|
||||||
|
"""Return None when Content-Length is already over the limit."""
|
||||||
|
response = _ChunkedResponse(
|
||||||
|
(),
|
||||||
|
content_length=_MAX_JSON_RESPONSE_BYTES + 1,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await _read_limited_response_body(response)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
async def test_returns_none_when_streamed_body_is_too_large(self) -> None:
|
||||||
|
"""Return None when chunked content grows past the limit."""
|
||||||
|
response = _ChunkedResponse(
|
||||||
|
(
|
||||||
|
b"x" * _MAX_JSON_RESPONSE_BYTES,
|
||||||
|
b"x",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await _read_limited_response_body(response)
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetStreamState:
|
||||||
|
"""Stream observation retrieval from the status API."""
|
||||||
|
|
||||||
|
@time_machine.travel(datetime(2026, 3, 13, 12, 0, 0, tzinfo=UTC))
|
||||||
async def test_returns_state_on_success(
|
async def test_returns_state_on_success(
|
||||||
self, owncast_client: OwncastClient
|
self, owncast_client: OwncastClient
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Return a StreamState with correct fields on a valid 200 response."""
|
"""Return a StreamStateObservation with correct fields on a valid response."""
|
||||||
with aioresponses() as mocked:
|
with aioresponses() as mocked:
|
||||||
mocked.get(
|
mocked.get(
|
||||||
"https://stream.logal.dev/api/status",
|
"https://stream.logal.dev/api/status",
|
||||||
@@ -65,14 +168,14 @@ class TestGetStreamState:
|
|||||||
result.title
|
result.title
|
||||||
== "I think I can do this... Let's start a nuclear reaction - Playing Nucleares!" # noqa: E501
|
== "I think I can do this... Let's start a nuclear reaction - Playing Nucleares!" # noqa: E501
|
||||||
)
|
)
|
||||||
assert result.last_connect_time is None
|
assert result.online is False
|
||||||
assert result.last_disconnect_time == "2026-03-04T21:05:32-05:00"
|
assert result.observed_at == "2026-03-13T12:00:00+00:00"
|
||||||
|
|
||||||
async def test_returns_none_on_missing_field(
|
async def test_returns_none_on_missing_field(
|
||||||
self, owncast_client: OwncastClient
|
self, owncast_client: OwncastClient
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Return None when the response is missing required fields."""
|
"""Return None when the response is missing required fields."""
|
||||||
incomplete = {"streamTitle": "Test Stream", "online": True}
|
incomplete = {"streamTitle": "Test Stream"}
|
||||||
with aioresponses() as mocked:
|
with aioresponses() as mocked:
|
||||||
mocked.get(
|
mocked.get(
|
||||||
"https://stream.logal.dev/api/status",
|
"https://stream.logal.dev/api/status",
|
||||||
@@ -82,6 +185,23 @@ class TestGetStreamState:
|
|||||||
|
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
async def test_returns_none_on_invalid_field_type(
|
||||||
|
self, owncast_client: OwncastClient
|
||||||
|
) -> None:
|
||||||
|
"""Return None when the response has malformed field types."""
|
||||||
|
malformed = {
|
||||||
|
**VALID_STATUS_RESPONSE,
|
||||||
|
"streamTitle": 123,
|
||||||
|
}
|
||||||
|
with aioresponses() as mocked:
|
||||||
|
mocked.get(
|
||||||
|
"https://stream.logal.dev/api/status",
|
||||||
|
body=json.dumps(malformed).encode(),
|
||||||
|
)
|
||||||
|
result = await owncast_client.get_stream_state("stream.logal.dev")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
async def test_returns_none_on_invalid_json(
|
async def test_returns_none_on_invalid_json(
|
||||||
self, owncast_client: OwncastClient
|
self, owncast_client: OwncastClient
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -95,6 +215,32 @@ class TestGetStreamState:
|
|||||||
|
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
async def test_returns_none_on_non_object_json(
|
||||||
|
self, owncast_client: OwncastClient
|
||||||
|
) -> None:
|
||||||
|
"""Return None when the response JSON is not an object."""
|
||||||
|
with aioresponses() as mocked:
|
||||||
|
mocked.get(
|
||||||
|
"https://stream.logal.dev/api/status",
|
||||||
|
body=json.dumps([]).encode(),
|
||||||
|
)
|
||||||
|
result = await owncast_client.get_stream_state("stream.logal.dev")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
async def test_returns_none_on_oversized_json(
|
||||||
|
self, owncast_client: OwncastClient
|
||||||
|
) -> None:
|
||||||
|
"""Return None when the response body is too large."""
|
||||||
|
with aioresponses() as mocked:
|
||||||
|
mocked.get(
|
||||||
|
"https://stream.logal.dev/api/status",
|
||||||
|
body=b" " * (_MAX_JSON_RESPONSE_BYTES + 1),
|
||||||
|
)
|
||||||
|
result = await owncast_client.get_stream_state("stream.logal.dev")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
async def test_returns_none_on_non_200(self, owncast_client: OwncastClient) -> None:
|
async def test_returns_none_on_non_200(self, owncast_client: OwncastClient) -> None:
|
||||||
"""Return None when the response status is not 200."""
|
"""Return None when the response status is not 200."""
|
||||||
with aioresponses() as mocked:
|
with aioresponses() as mocked:
|
||||||
@@ -126,7 +272,7 @@ class TestGetStreamConfig:
|
|||||||
async def test_returns_config_on_success(
|
async def test_returns_config_on_success(
|
||||||
self, owncast_client: OwncastClient
|
self, owncast_client: OwncastClient
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Return a StreamConfig with correct fields on a valid 200 response."""
|
"""Return a StreamConfigObservation with fields from a valid response."""
|
||||||
with aioresponses() as mocked:
|
with aioresponses() as mocked:
|
||||||
mocked.get(
|
mocked.get(
|
||||||
"https://stream.logal.dev/api/config",
|
"https://stream.logal.dev/api/config",
|
||||||
@@ -136,7 +282,7 @@ class TestGetStreamConfig:
|
|||||||
|
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result.name == "LogalDeveloper's Live Stream"
|
assert result.name == "LogalDeveloper's Live Stream"
|
||||||
assert result.tags == [
|
assert result.tags == (
|
||||||
"video games",
|
"video games",
|
||||||
"chatting",
|
"chatting",
|
||||||
"casual",
|
"casual",
|
||||||
@@ -144,7 +290,7 @@ class TestGetStreamConfig:
|
|||||||
"streaming",
|
"streaming",
|
||||||
"owncast",
|
"owncast",
|
||||||
"variety",
|
"variety",
|
||||||
]
|
)
|
||||||
|
|
||||||
async def test_returns_none_on_invalid_json(
|
async def test_returns_none_on_invalid_json(
|
||||||
self, owncast_client: OwncastClient
|
self, owncast_client: OwncastClient
|
||||||
@@ -159,6 +305,49 @@ class TestGetStreamConfig:
|
|||||||
|
|
||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
async def test_returns_none_on_non_object_json(
|
||||||
|
self, owncast_client: OwncastClient
|
||||||
|
) -> None:
|
||||||
|
"""Return None when the response JSON is not an object."""
|
||||||
|
with aioresponses() as mocked:
|
||||||
|
mocked.get(
|
||||||
|
"https://stream.logal.dev/api/config",
|
||||||
|
body=json.dumps([]).encode(),
|
||||||
|
)
|
||||||
|
result = await owncast_client.get_stream_config("stream.logal.dev")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
async def test_returns_none_on_invalid_field_type(
|
||||||
|
self, owncast_client: OwncastClient
|
||||||
|
) -> None:
|
||||||
|
"""Return None when the response has malformed field types."""
|
||||||
|
malformed = {
|
||||||
|
**VALID_CONFIG_RESPONSE,
|
||||||
|
"tags": "gaming",
|
||||||
|
}
|
||||||
|
with aioresponses() as mocked:
|
||||||
|
mocked.get(
|
||||||
|
"https://stream.logal.dev/api/config",
|
||||||
|
body=json.dumps(malformed).encode(),
|
||||||
|
)
|
||||||
|
result = await owncast_client.get_stream_config("stream.logal.dev")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
async def test_returns_none_on_oversized_json(
|
||||||
|
self, owncast_client: OwncastClient
|
||||||
|
) -> None:
|
||||||
|
"""Return None when the response body is too large."""
|
||||||
|
with aioresponses() as mocked:
|
||||||
|
mocked.get(
|
||||||
|
"https://stream.logal.dev/api/config",
|
||||||
|
body=b" " * (_MAX_JSON_RESPONSE_BYTES + 1),
|
||||||
|
)
|
||||||
|
result = await owncast_client.get_stream_config("stream.logal.dev")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
async def test_returns_none_on_non_200(self, owncast_client: OwncastClient) -> None:
|
async def test_returns_none_on_non_200(self, owncast_client: OwncastClient) -> None:
|
||||||
"""Return None when the response status is not 200."""
|
"""Return None when the response status is not 200."""
|
||||||
with aioresponses() as mocked:
|
with aioresponses() as mocked:
|
||||||
@@ -215,7 +404,7 @@ class TestValidateInstance:
|
|||||||
|
|
||||||
|
|
||||||
class TestResponseTimeMetrics:
|
class TestResponseTimeMetrics:
|
||||||
"""Response time histogram recording."""
|
"""Response time gauge recording."""
|
||||||
|
|
||||||
async def test_records_on_success(self) -> None:
|
async def test_records_on_success(self) -> None:
|
||||||
"""Record response time on a successful request."""
|
"""Record response time on a successful request."""
|
||||||
@@ -225,6 +414,7 @@ class TestResponseTimeMetrics:
|
|||||||
version="0.0.0",
|
version="0.0.0",
|
||||||
metrics=metrics,
|
metrics=metrics,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
with aioresponses() as mocked:
|
with aioresponses() as mocked:
|
||||||
mocked.get(
|
mocked.get(
|
||||||
"https://example.com/api/status",
|
"https://example.com/api/status",
|
||||||
@@ -233,6 +423,7 @@ class TestResponseTimeMetrics:
|
|||||||
await client.get_stream_state("example.com")
|
await client.get_stream_state("example.com")
|
||||||
output = generate_metrics_output(metrics)
|
output = generate_metrics_output(metrics)
|
||||||
assert 'owncastsentry_api_response_seconds{domain="example.com"}' in output
|
assert 'owncastsentry_api_response_seconds{domain="example.com"}' in output
|
||||||
|
finally:
|
||||||
await client.close()
|
await client.close()
|
||||||
|
|
||||||
async def test_no_observation_on_failure(self) -> None:
|
async def test_no_observation_on_failure(self) -> None:
|
||||||
@@ -243,6 +434,7 @@ class TestResponseTimeMetrics:
|
|||||||
version="0.0.0",
|
version="0.0.0",
|
||||||
metrics=metrics,
|
metrics=metrics,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
with aioresponses() as mocked:
|
with aioresponses() as mocked:
|
||||||
mocked.get(
|
mocked.get(
|
||||||
"https://example.com/api/status",
|
"https://example.com/api/status",
|
||||||
@@ -250,7 +442,10 @@ class TestResponseTimeMetrics:
|
|||||||
)
|
)
|
||||||
await client.get_stream_state("example.com")
|
await client.get_stream_state("example.com")
|
||||||
output = generate_metrics_output(metrics)
|
output = generate_metrics_output(metrics)
|
||||||
assert 'owncastsentry_api_response_seconds{domain="example.com"}' not in output
|
assert (
|
||||||
|
'owncastsentry_api_response_seconds{domain="example.com"}' not in output
|
||||||
|
)
|
||||||
|
finally:
|
||||||
await client.close()
|
await client.close()
|
||||||
|
|
||||||
async def test_no_observation_on_connection_error(self) -> None:
|
async def test_no_observation_on_connection_error(self) -> None:
|
||||||
@@ -261,6 +456,7 @@ class TestResponseTimeMetrics:
|
|||||||
version="0.0.0",
|
version="0.0.0",
|
||||||
metrics=metrics,
|
metrics=metrics,
|
||||||
)
|
)
|
||||||
|
try:
|
||||||
with aioresponses() as mocked:
|
with aioresponses() as mocked:
|
||||||
mocked.get(
|
mocked.get(
|
||||||
"https://example.com/api/status",
|
"https://example.com/api/status",
|
||||||
@@ -268,7 +464,10 @@ class TestResponseTimeMetrics:
|
|||||||
)
|
)
|
||||||
await client.get_stream_state("example.com")
|
await client.get_stream_state("example.com")
|
||||||
output = generate_metrics_output(metrics)
|
output = generate_metrics_output(metrics)
|
||||||
assert 'owncastsentry_api_response_seconds{domain="example.com"}' not in output
|
assert (
|
||||||
|
'owncastsentry_api_response_seconds{domain="example.com"}' not in output
|
||||||
|
)
|
||||||
|
finally:
|
||||||
await client.close()
|
await client.close()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,483 @@
|
|||||||
|
# Copyright 2026 Logan Fick
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Tests for database repository classes."""
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from owncastsentry.repository import _normalize_legacy_status_since
|
||||||
|
from owncastsentry.types import (
|
||||||
|
UNKNOWN_STATUS_THRESHOLD,
|
||||||
|
AlreadySubscribedError,
|
||||||
|
NotSubscribedError,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from mautrix.util.async_db import Database
|
||||||
|
|
||||||
|
from owncastsentry.repository import StreamRepository, SubscriptionRepository
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamSchema:
|
||||||
|
"""Streams table schema after migrations."""
|
||||||
|
|
||||||
|
async def test_uses_online_and_status_since_columns(
|
||||||
|
self, database: Database
|
||||||
|
) -> None:
|
||||||
|
"""Create the current schema without legacy connect/disconnect columns."""
|
||||||
|
async with database.acquire() as conn:
|
||||||
|
rows = await conn.fetch("PRAGMA table_info(streams)")
|
||||||
|
|
||||||
|
assert [row["name"] for row in rows] == [
|
||||||
|
"domain",
|
||||||
|
"name",
|
||||||
|
"title",
|
||||||
|
"online",
|
||||||
|
"status_since",
|
||||||
|
"failure_counter",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeLegacyStatusSince:
|
||||||
|
"""Legacy timestamp normalization used by the v4 migration."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("value", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
"2026-05-21T19:06:24Z",
|
||||||
|
"2026-05-21T19:06:24+00:00",
|
||||||
|
id="utc-z",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"2026-05-21T19:38:40+02:00",
|
||||||
|
"2026-05-21T17:38:40+00:00",
|
||||||
|
id="positive-offset",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"2026-05-17T21:23:27-04:00",
|
||||||
|
"2026-05-18T01:23:27+00:00",
|
||||||
|
id="negative-offset",
|
||||||
|
),
|
||||||
|
pytest.param("", None, id="blank"),
|
||||||
|
pytest.param(None, None, id="null"),
|
||||||
|
pytest.param("not a timestamp", None, id="malformed"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_normalizes_parseable_aware_timestamps(
|
||||||
|
self, value: str | None, expected: str | None
|
||||||
|
) -> None:
|
||||||
|
"""Normalize parseable legacy values and ignore unusable ones."""
|
||||||
|
assert _normalize_legacy_status_since(value) == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamExists:
|
||||||
|
"""Stream existence checks."""
|
||||||
|
|
||||||
|
async def test_returns_true_for_existing_stream(
|
||||||
|
self, stream_repo: StreamRepository
|
||||||
|
) -> None:
|
||||||
|
"""Return True when the stream exists in the database."""
|
||||||
|
await stream_repo.create("example.com")
|
||||||
|
assert await stream_repo.exists("example.com") is True
|
||||||
|
|
||||||
|
async def test_returns_false_for_missing_stream(
|
||||||
|
self, stream_repo: StreamRepository
|
||||||
|
) -> None:
|
||||||
|
"""Return False when the stream does not exist in the database."""
|
||||||
|
assert await stream_repo.exists("missing.com") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamCreate:
|
||||||
|
"""Stream creation behavior."""
|
||||||
|
|
||||||
|
async def test_returns_true_when_created(
|
||||||
|
self, stream_repo: StreamRepository
|
||||||
|
) -> None:
|
||||||
|
"""Return True when a stream row is inserted."""
|
||||||
|
assert await stream_repo.create("example.com") is True
|
||||||
|
|
||||||
|
async def test_returns_false_when_existing(
|
||||||
|
self, stream_repo: StreamRepository
|
||||||
|
) -> None:
|
||||||
|
"""Return False when a stream row already exists."""
|
||||||
|
await stream_repo.create("example.com")
|
||||||
|
assert await stream_repo.create("example.com") is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamDelete:
|
||||||
|
"""Stream record deletion."""
|
||||||
|
|
||||||
|
async def test_removes_stream_record(self, stream_repo: StreamRepository) -> None:
|
||||||
|
"""Remove the stream record so get_by_domain returns None."""
|
||||||
|
await stream_repo.create("example.com")
|
||||||
|
await stream_repo.delete("example.com")
|
||||||
|
assert await stream_repo.get_by_domain("example.com") is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamUpdate:
|
||||||
|
"""Partial stream state updates."""
|
||||||
|
|
||||||
|
async def test_updates_only_supplied_fields(
|
||||||
|
self, stream_repo: StreamRepository
|
||||||
|
) -> None:
|
||||||
|
"""Update supplied fields while leaving omitted fields unchanged."""
|
||||||
|
await stream_repo.create("example.com")
|
||||||
|
await stream_repo.update(
|
||||||
|
"example.com",
|
||||||
|
name="Original Name",
|
||||||
|
title="Original Title",
|
||||||
|
online=True,
|
||||||
|
status_since="2026-01-01T12:00:00+00:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
await stream_repo.update("example.com", title="Updated Title")
|
||||||
|
|
||||||
|
state = await stream_repo.get_by_domain("example.com")
|
||||||
|
assert state is not None
|
||||||
|
assert state.name == "Original Name"
|
||||||
|
assert state.title == "Updated Title"
|
||||||
|
assert state.online is True
|
||||||
|
assert state.status_since == "2026-01-01T12:00:00+00:00"
|
||||||
|
|
||||||
|
async def test_can_set_nullable_field_to_null(
|
||||||
|
self, stream_repo: StreamRepository
|
||||||
|
) -> None:
|
||||||
|
"""Treat None as a supplied NULL value, not an omitted field."""
|
||||||
|
await stream_repo.create("example.com")
|
||||||
|
await stream_repo.update(
|
||||||
|
"example.com",
|
||||||
|
name="Original Name",
|
||||||
|
title="Original Title",
|
||||||
|
status_since="2026-01-01T12:00:00+00:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
await stream_repo.update("example.com", title=None)
|
||||||
|
|
||||||
|
state = await stream_repo.get_by_domain("example.com")
|
||||||
|
assert state is not None
|
||||||
|
assert state.name == "Original Name"
|
||||||
|
assert state.title is None
|
||||||
|
assert state.status_since == "2026-01-01T12:00:00+00:00"
|
||||||
|
|
||||||
|
async def test_accepts_no_supplied_fields(
|
||||||
|
self, stream_repo: StreamRepository
|
||||||
|
) -> None:
|
||||||
|
"""Leave the row unchanged when no partial fields are supplied."""
|
||||||
|
await stream_repo.create("example.com")
|
||||||
|
await stream_repo.update(
|
||||||
|
"example.com",
|
||||||
|
title="Original Title",
|
||||||
|
status_since="2026-01-01T12:00:00+00:00",
|
||||||
|
)
|
||||||
|
|
||||||
|
await stream_repo.update("example.com")
|
||||||
|
|
||||||
|
state = await stream_repo.get_by_domain("example.com")
|
||||||
|
assert state is not None
|
||||||
|
assert state.title == "Original Title"
|
||||||
|
assert state.status_since == "2026-01-01T12:00:00+00:00"
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetSubscribedStreamsForRoom:
|
||||||
|
"""Subscribed stream lookup by room."""
|
||||||
|
|
||||||
|
async def test_returns_all_domains_for_room(
|
||||||
|
self,
|
||||||
|
stream_repo: StreamRepository,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> None:
|
||||||
|
"""Return all domains a room is subscribed to."""
|
||||||
|
await stream_repo.create("alpha.com")
|
||||||
|
await stream_repo.create("beta.com")
|
||||||
|
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||||
|
await subscription_repo.add("beta.com", "!room1:example.com")
|
||||||
|
|
||||||
|
result = await subscription_repo.get_subscribed_streams_for_room(
|
||||||
|
"!room1:example.com"
|
||||||
|
)
|
||||||
|
assert sorted(result) == ["alpha.com", "beta.com"]
|
||||||
|
|
||||||
|
async def test_returns_empty_list_for_unsubscribed_room(
|
||||||
|
self, subscription_repo: SubscriptionRepository
|
||||||
|
) -> None:
|
||||||
|
"""Return an empty list when the room has no subscriptions."""
|
||||||
|
result = await subscription_repo.get_subscribed_streams_for_room(
|
||||||
|
"!nobody:example.com"
|
||||||
|
)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestHasRoomSubscriptions:
|
||||||
|
"""Room subscription existence checks."""
|
||||||
|
|
||||||
|
async def test_returns_true_for_subscribed_room(
|
||||||
|
self, subscription_repo: SubscriptionRepository
|
||||||
|
) -> None:
|
||||||
|
"""Return True when the room has at least one subscription."""
|
||||||
|
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||||
|
|
||||||
|
assert await subscription_repo.has_room_subscriptions("!room1:example.com")
|
||||||
|
|
||||||
|
async def test_returns_false_for_unsubscribed_room(
|
||||||
|
self, subscription_repo: SubscriptionRepository
|
||||||
|
) -> None:
|
||||||
|
"""Return False when the room has no subscriptions."""
|
||||||
|
assert not await subscription_repo.has_room_subscriptions("!nobody:example.com")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetRoomSubscriptions:
|
||||||
|
"""Resolved room subscription lookup."""
|
||||||
|
|
||||||
|
async def test_returns_sorted_stream_states_and_skips_missing_rows(
|
||||||
|
self,
|
||||||
|
stream_repo: StreamRepository,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> None:
|
||||||
|
"""Return sorted resolved subscriptions and skip missing stream rows."""
|
||||||
|
await stream_repo.create("beta.example")
|
||||||
|
await stream_repo.update("beta.example", name="Beta")
|
||||||
|
await stream_repo.create("alpha.example")
|
||||||
|
await stream_repo.update("alpha.example", name="Alpha")
|
||||||
|
await subscription_repo.add("beta.example", "!room:example.com")
|
||||||
|
await subscription_repo.add("missing.example", "!room:example.com")
|
||||||
|
await subscription_repo.add("alpha.example", "!room:example.com")
|
||||||
|
|
||||||
|
subscriptions = await subscription_repo.get_room_subscriptions(
|
||||||
|
"!room:example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [subscription.domain for subscription in subscriptions] == [
|
||||||
|
"alpha.example",
|
||||||
|
"beta.example",
|
||||||
|
]
|
||||||
|
assert [subscription.stream_state.name for subscription in subscriptions] == [
|
||||||
|
"Alpha",
|
||||||
|
"Beta",
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_returns_empty_list_for_unsubscribed_room(
|
||||||
|
self, subscription_repo: SubscriptionRepository
|
||||||
|
) -> None:
|
||||||
|
"""Return an empty list when the room has no resolved subscriptions."""
|
||||||
|
subscriptions = await subscription_repo.get_room_subscriptions(
|
||||||
|
"!nobody:example.com"
|
||||||
|
)
|
||||||
|
assert subscriptions == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetLiveRoomSubscriptions:
|
||||||
|
"""Resolved live room subscription lookup."""
|
||||||
|
|
||||||
|
async def test_returns_online_streams_and_skips_inactive_states(
|
||||||
|
self,
|
||||||
|
stream_repo: StreamRepository,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> None:
|
||||||
|
"""Return only streams whose derived status is ONLINE."""
|
||||||
|
await stream_repo.create("offline.example")
|
||||||
|
await stream_repo.update(
|
||||||
|
"offline.example",
|
||||||
|
name="Offline",
|
||||||
|
status_since="2026-01-01T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
await stream_repo.create("online.example")
|
||||||
|
await stream_repo.update(
|
||||||
|
"online.example",
|
||||||
|
name="Online",
|
||||||
|
online=True,
|
||||||
|
status_since="2026-01-01T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
await stream_repo.create("unknown.example")
|
||||||
|
await stream_repo.update(
|
||||||
|
"unknown.example",
|
||||||
|
name="Unknown",
|
||||||
|
online=True,
|
||||||
|
status_since="2026-01-01T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
for _ in range(UNKNOWN_STATUS_THRESHOLD + 1):
|
||||||
|
await stream_repo.increment_failure_counter("unknown.example")
|
||||||
|
|
||||||
|
await subscription_repo.add("offline.example", "!room:example.com")
|
||||||
|
await subscription_repo.add("online.example", "!room:example.com")
|
||||||
|
await subscription_repo.add("unknown.example", "!room:example.com")
|
||||||
|
|
||||||
|
subscriptions = await subscription_repo.get_live_room_subscriptions(
|
||||||
|
"!room:example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [subscription.domain for subscription in subscriptions] == [
|
||||||
|
"online.example"
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_returns_empty_list_for_room_with_no_live_streams(
|
||||||
|
self,
|
||||||
|
stream_repo: StreamRepository,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> None:
|
||||||
|
"""Return an empty list when no subscribed streams are live."""
|
||||||
|
await stream_repo.create("offline.example")
|
||||||
|
await subscription_repo.add("offline.example", "!room:example.com")
|
||||||
|
|
||||||
|
subscriptions = await subscription_repo.get_live_room_subscriptions(
|
||||||
|
"!room:example.com"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert subscriptions == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestAddSubscription:
|
||||||
|
"""Subscription creation behavior."""
|
||||||
|
|
||||||
|
async def test_adds_subscription(
|
||||||
|
self, subscription_repo: SubscriptionRepository
|
||||||
|
) -> None:
|
||||||
|
"""Add a subscription row."""
|
||||||
|
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||||
|
assert await subscription_repo.get_subscribed_rooms("alpha.com") == [
|
||||||
|
"!room1:example.com"
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_raises_when_existing(
|
||||||
|
self, subscription_repo: SubscriptionRepository
|
||||||
|
) -> None:
|
||||||
|
"""Raise AlreadySubscribedError when a subscription already exists."""
|
||||||
|
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||||
|
with pytest.raises(AlreadySubscribedError):
|
||||||
|
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoveSubscription:
|
||||||
|
"""Subscription removal behavior."""
|
||||||
|
|
||||||
|
async def test_removes_subscription(
|
||||||
|
self, subscription_repo: SubscriptionRepository
|
||||||
|
) -> None:
|
||||||
|
"""Remove an existing subscription row."""
|
||||||
|
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||||
|
await subscription_repo.remove("alpha.com", "!room1:example.com")
|
||||||
|
assert await subscription_repo.get_subscribed_rooms("alpha.com") == []
|
||||||
|
|
||||||
|
async def test_raises_when_missing(
|
||||||
|
self, subscription_repo: SubscriptionRepository
|
||||||
|
) -> None:
|
||||||
|
"""Raise NotSubscribedError when no subscription exists."""
|
||||||
|
with pytest.raises(NotSubscribedError):
|
||||||
|
await subscription_repo.remove("alpha.com", "!room1:example.com")
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetAllSubscribedDomains:
|
||||||
|
"""Unique subscribed domain retrieval."""
|
||||||
|
|
||||||
|
async def test_returns_each_domain_once(
|
||||||
|
self,
|
||||||
|
stream_repo: StreamRepository,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> None:
|
||||||
|
"""Return each domain once even with multiple subscriptions."""
|
||||||
|
await stream_repo.create("alpha.com")
|
||||||
|
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||||
|
await subscription_repo.add("alpha.com", "!room2:example.com")
|
||||||
|
|
||||||
|
result = await subscription_repo.get_all_subscribed_domains()
|
||||||
|
assert result == ["alpha.com"]
|
||||||
|
|
||||||
|
async def test_returns_empty_list_with_no_subscriptions(
|
||||||
|
self, subscription_repo: SubscriptionRepository
|
||||||
|
) -> None:
|
||||||
|
"""Return an empty list when there are no subscriptions."""
|
||||||
|
result = await subscription_repo.get_all_subscribed_domains()
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestCountByDomain:
|
||||||
|
"""Subscription count by domain."""
|
||||||
|
|
||||||
|
async def test_returns_correct_count(
|
||||||
|
self,
|
||||||
|
stream_repo: StreamRepository,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> None:
|
||||||
|
"""Return the correct subscription count for a domain."""
|
||||||
|
await stream_repo.create("alpha.com")
|
||||||
|
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||||
|
await subscription_repo.add("alpha.com", "!room2:example.com")
|
||||||
|
|
||||||
|
assert await subscription_repo.count_by_domain("alpha.com") == 2
|
||||||
|
|
||||||
|
async def test_returns_zero_for_unknown_domain(
|
||||||
|
self, subscription_repo: SubscriptionRepository
|
||||||
|
) -> None:
|
||||||
|
"""Return 0 for a domain with no subscriptions."""
|
||||||
|
assert await subscription_repo.count_by_domain("unknown.com") == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestCountByDomains:
|
||||||
|
"""Bulk subscription counts by domain."""
|
||||||
|
|
||||||
|
async def test_returns_counts_for_requested_domains(
|
||||||
|
self,
|
||||||
|
stream_repo: StreamRepository,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> None:
|
||||||
|
"""Return counts for requested domains and zero for missing domains."""
|
||||||
|
await stream_repo.create("alpha.com")
|
||||||
|
await stream_repo.create("beta.com")
|
||||||
|
await stream_repo.create("ignored.com")
|
||||||
|
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||||
|
await subscription_repo.add("alpha.com", "!room2:example.com")
|
||||||
|
await subscription_repo.add("beta.com", "!room3:example.com")
|
||||||
|
await subscription_repo.add("ignored.com", "!room4:example.com")
|
||||||
|
|
||||||
|
assert await subscription_repo.count_by_domains(
|
||||||
|
["beta.com", "missing.com", "alpha.com"]
|
||||||
|
) == {
|
||||||
|
"beta.com": 1,
|
||||||
|
"missing.com": 0,
|
||||||
|
"alpha.com": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def test_returns_empty_dict_for_empty_domain_list(
|
||||||
|
self, subscription_repo: SubscriptionRepository
|
||||||
|
) -> None:
|
||||||
|
"""Return an empty mapping when no domains are requested."""
|
||||||
|
assert await subscription_repo.count_by_domains([]) == {}
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteAllForDomain:
|
||||||
|
"""Bulk subscription deletion by domain."""
|
||||||
|
|
||||||
|
async def test_deletes_all_subscriptions_and_returns_count(
|
||||||
|
self,
|
||||||
|
stream_repo: StreamRepository,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> None:
|
||||||
|
"""Delete all subscriptions for the domain and return the count."""
|
||||||
|
await stream_repo.create("alpha.com")
|
||||||
|
await subscription_repo.add("alpha.com", "!room1:example.com")
|
||||||
|
await subscription_repo.add("alpha.com", "!room2:example.com")
|
||||||
|
|
||||||
|
deleted = await subscription_repo.delete_all_for_domain("alpha.com")
|
||||||
|
assert deleted == 2
|
||||||
|
|
||||||
|
rooms = await subscription_repo.get_subscribed_rooms("alpha.com")
|
||||||
|
assert rooms == []
|
||||||
|
|
||||||
|
async def test_returns_zero_for_unknown_domain(
|
||||||
|
self, subscription_repo: SubscriptionRepository
|
||||||
|
) -> None:
|
||||||
|
"""Return 0 when deleting subscriptions for an unknown domain."""
|
||||||
|
assert await subscription_repo.delete_all_for_domain("unknown.com") == 0
|
||||||
+757
-187
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,284 @@
|
|||||||
|
# Copyright 2026 Logan Fick
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Tests for subscription manager business logic."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from owncastsentry.subscription_manager import SubscriptionManager, _domainify
|
||||||
|
from owncastsentry.types import (
|
||||||
|
UNKNOWN_STATUS_THRESHOLD,
|
||||||
|
AlreadySubscribedError,
|
||||||
|
InvalidOwncastInstanceError,
|
||||||
|
NotSubscribedError,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from owncastsentry.repository import StreamRepository, SubscriptionRepository
|
||||||
|
|
||||||
|
|
||||||
|
class _StubOwncastClient:
|
||||||
|
"""Owncast client stub for validation-only manager tests."""
|
||||||
|
|
||||||
|
def __init__(self, *, valid: bool = True) -> None:
|
||||||
|
"""Initialize the stub with a fixed validation result."""
|
||||||
|
self.valid = valid
|
||||||
|
self.validated_domains: list[str] = []
|
||||||
|
|
||||||
|
async def validate_instance(self, domain: str) -> bool:
|
||||||
|
"""Record the domain and return the configured validation result."""
|
||||||
|
self.validated_domains.append(domain)
|
||||||
|
return self.valid
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def owncast_client() -> _StubOwncastClient:
|
||||||
|
"""Return a validation-only Owncast client stub."""
|
||||||
|
return _StubOwncastClient()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def manager(
|
||||||
|
owncast_client: _StubOwncastClient,
|
||||||
|
stream_repo: StreamRepository,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> SubscriptionManager:
|
||||||
|
"""SubscriptionManager built directly for unit tests."""
|
||||||
|
return SubscriptionManager(
|
||||||
|
owncast_client=owncast_client, # type: ignore[arg-type]
|
||||||
|
stream_repo=stream_repo,
|
||||||
|
subscription_repo=subscription_repo,
|
||||||
|
logger=logging.getLogger("test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDomainify:
|
||||||
|
"""Domain extraction and sanitization from user input."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("input_url", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param("example.com", "example.com", id="bare-domain"),
|
||||||
|
pytest.param(" example.com ", "example.com", id="surrounding-whitespace"),
|
||||||
|
pytest.param("https://example.com", "example.com", id="https-url"),
|
||||||
|
pytest.param("http://example.com", "example.com", id="http-url"),
|
||||||
|
pytest.param("https://example.com:8080", "example.com", id="url-with-port"),
|
||||||
|
pytest.param(
|
||||||
|
"https://example.com/path/to/page",
|
||||||
|
"example.com",
|
||||||
|
id="url-with-path",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"user@stream.logal.dev",
|
||||||
|
"stream.logal.dev",
|
||||||
|
id="email-style",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
"matrix@notify@stream.logal.dev",
|
||||||
|
"stream.logal.dev",
|
||||||
|
id="last-at-sign-wins",
|
||||||
|
),
|
||||||
|
pytest.param("EXAMPLE.COM", "example.com", id="uppercase"),
|
||||||
|
pytest.param("exam!ple.com", "example.com", id="special-chars-stripped"),
|
||||||
|
pytest.param(".example.com.", "example.com", id="leading-trailing-dots"),
|
||||||
|
pytest.param("-example.com-", "example.com", id="leading-trailing-hyphens"),
|
||||||
|
pytest.param(
|
||||||
|
"sub.domain.example.com",
|
||||||
|
"sub.domain.example.com",
|
||||||
|
id="subdomain",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_extracts_domain(self, input_url: str, expected: str) -> None:
|
||||||
|
"""Extract and sanitize the domain from various input formats."""
|
||||||
|
assert _domainify(input_url) == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestManagerSubscribe:
|
||||||
|
"""SubscriptionManager subscribe workflow."""
|
||||||
|
|
||||||
|
async def test_first_subscription_validates_and_creates_stream(
|
||||||
|
self,
|
||||||
|
manager: SubscriptionManager,
|
||||||
|
owncast_client: _StubOwncastClient,
|
||||||
|
stream_repo: StreamRepository,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> None:
|
||||||
|
"""First subscription validates the instance and creates stream state."""
|
||||||
|
domain = await manager.subscribe(
|
||||||
|
"!room:example.com", "https://Stream.Example/foo"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert domain == "stream.example"
|
||||||
|
assert owncast_client.validated_domains == ["stream.example"]
|
||||||
|
assert await stream_repo.exists("stream.example") is True
|
||||||
|
assert await subscription_repo.get_subscribed_rooms("stream.example") == [
|
||||||
|
"!room:example.com"
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_invalid_first_subscription_raises(
|
||||||
|
self,
|
||||||
|
manager: SubscriptionManager,
|
||||||
|
owncast_client: _StubOwncastClient,
|
||||||
|
stream_repo: StreamRepository,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> None:
|
||||||
|
"""Invalid first-time Owncast validation raises a domain error."""
|
||||||
|
owncast_client.valid = False
|
||||||
|
|
||||||
|
with pytest.raises(InvalidOwncastInstanceError) as exc_info:
|
||||||
|
await manager.subscribe("!room:example.com", "bad.example")
|
||||||
|
|
||||||
|
assert exc_info.value.domain == "bad.example"
|
||||||
|
assert owncast_client.validated_domains == ["bad.example"]
|
||||||
|
assert await stream_repo.exists("bad.example") is False
|
||||||
|
assert await subscription_repo.get_subscribed_rooms("bad.example") == []
|
||||||
|
|
||||||
|
async def test_duplicate_subscription_raises(
|
||||||
|
self,
|
||||||
|
manager: SubscriptionManager,
|
||||||
|
owncast_client: _StubOwncastClient,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> None:
|
||||||
|
"""Duplicate room subscription raises AlreadySubscribedError."""
|
||||||
|
await manager.subscribe("!room:example.com", "stream.example")
|
||||||
|
|
||||||
|
with pytest.raises(AlreadySubscribedError) as exc_info:
|
||||||
|
await manager.subscribe("!room:example.com", "stream.example")
|
||||||
|
|
||||||
|
assert exc_info.value.domain == "stream.example"
|
||||||
|
assert owncast_client.validated_domains == ["stream.example"]
|
||||||
|
assert await subscription_repo.get_subscribed_rooms("stream.example") == [
|
||||||
|
"!room:example.com"
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_existing_stream_new_room_skips_validation(
|
||||||
|
self,
|
||||||
|
manager: SubscriptionManager,
|
||||||
|
owncast_client: _StubOwncastClient,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> None:
|
||||||
|
"""Domains with existing subscriptions skip remote validation."""
|
||||||
|
await manager.subscribe("!room1:example.com", "stream.example")
|
||||||
|
owncast_client.valid = False
|
||||||
|
|
||||||
|
domain = await manager.subscribe("!room2:example.com", "stream.example")
|
||||||
|
|
||||||
|
assert domain == "stream.example"
|
||||||
|
assert owncast_client.validated_domains == ["stream.example"]
|
||||||
|
rooms = await subscription_repo.get_subscribed_rooms("stream.example")
|
||||||
|
assert sorted(rooms) == [
|
||||||
|
"!room1:example.com",
|
||||||
|
"!room2:example.com",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestManagerUnsubscribe:
|
||||||
|
"""SubscriptionManager unsubscribe workflow."""
|
||||||
|
|
||||||
|
async def test_removes_existing_subscription(
|
||||||
|
self,
|
||||||
|
manager: SubscriptionManager,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> None:
|
||||||
|
"""Existing room subscription is removed and its domain is returned."""
|
||||||
|
await manager.subscribe("!room:example.com", "stream.example")
|
||||||
|
|
||||||
|
domain = await manager.unsubscribe("!room:example.com", "stream.example")
|
||||||
|
|
||||||
|
assert domain == "stream.example"
|
||||||
|
assert await subscription_repo.get_subscribed_rooms("stream.example") == []
|
||||||
|
|
||||||
|
async def test_missing_subscription_raises(
|
||||||
|
self,
|
||||||
|
manager: SubscriptionManager,
|
||||||
|
) -> None:
|
||||||
|
"""Removing a non-existent subscription raises NotSubscribedError."""
|
||||||
|
with pytest.raises(NotSubscribedError) as exc_info:
|
||||||
|
await manager.unsubscribe("!room:example.com", "missing.example")
|
||||||
|
|
||||||
|
assert exc_info.value.domain == "missing.example"
|
||||||
|
|
||||||
|
|
||||||
|
class TestManagerListings:
|
||||||
|
"""SubscriptionManager room listing behavior."""
|
||||||
|
|
||||||
|
async def test_list_room_subscriptions_returns_sorted_stream_states(
|
||||||
|
self,
|
||||||
|
manager: SubscriptionManager,
|
||||||
|
stream_repo: StreamRepository,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> None:
|
||||||
|
"""Return sorted room subscriptions and skip missing stream rows."""
|
||||||
|
await stream_repo.create("beta.example")
|
||||||
|
await stream_repo.update("beta.example", name="Beta")
|
||||||
|
await stream_repo.create("alpha.example")
|
||||||
|
await stream_repo.update("alpha.example", name="Alpha")
|
||||||
|
await subscription_repo.add("beta.example", "!room:example.com")
|
||||||
|
await subscription_repo.add("missing.example", "!room:example.com")
|
||||||
|
await subscription_repo.add("alpha.example", "!room:example.com")
|
||||||
|
|
||||||
|
subscriptions = await manager.list_room_subscriptions("!room:example.com")
|
||||||
|
|
||||||
|
assert [subscription.domain for subscription in subscriptions] == [
|
||||||
|
"alpha.example",
|
||||||
|
"beta.example",
|
||||||
|
]
|
||||||
|
assert [subscription.stream_state.name for subscription in subscriptions] == [
|
||||||
|
"Alpha",
|
||||||
|
"Beta",
|
||||||
|
]
|
||||||
|
|
||||||
|
async def test_list_live_room_subscriptions_filters_online_streams(
|
||||||
|
self,
|
||||||
|
manager: SubscriptionManager,
|
||||||
|
stream_repo: StreamRepository,
|
||||||
|
subscription_repo: SubscriptionRepository,
|
||||||
|
) -> None:
|
||||||
|
"""Live listing includes only subscriptions with ONLINE stream status."""
|
||||||
|
await stream_repo.create("offline.example")
|
||||||
|
await stream_repo.update(
|
||||||
|
"offline.example",
|
||||||
|
name="Offline",
|
||||||
|
status_since="2026-01-01T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
await stream_repo.create("online.example")
|
||||||
|
await stream_repo.update(
|
||||||
|
"online.example",
|
||||||
|
name="Online",
|
||||||
|
online=True,
|
||||||
|
status_since="2026-01-01T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
await stream_repo.create("unknown.example")
|
||||||
|
await stream_repo.update(
|
||||||
|
"unknown.example",
|
||||||
|
name="Unknown",
|
||||||
|
online=True,
|
||||||
|
status_since="2026-01-01T00:00:00+00:00",
|
||||||
|
)
|
||||||
|
for _ in range(UNKNOWN_STATUS_THRESHOLD + 1):
|
||||||
|
await stream_repo.increment_failure_counter("unknown.example")
|
||||||
|
|
||||||
|
await subscription_repo.add("offline.example", "!room:example.com")
|
||||||
|
await subscription_repo.add("online.example", "!room:example.com")
|
||||||
|
await subscription_repo.add("unknown.example", "!room:example.com")
|
||||||
|
|
||||||
|
subscriptions = await manager.list_live_room_subscriptions("!room:example.com")
|
||||||
|
|
||||||
|
assert [subscription.domain for subscription in subscriptions] == [
|
||||||
|
"online.example"
|
||||||
|
]
|
||||||
@@ -0,0 +1,388 @@
|
|||||||
|
# Copyright 2026 Logan Fick
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
#
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
#
|
||||||
|
# Unless required by applicable law or agreed to in writing, software
|
||||||
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
# See the License for the specific language governing permissions and
|
||||||
|
# limitations under the License.
|
||||||
|
|
||||||
|
"""Tests for data models."""
|
||||||
|
|
||||||
|
from dataclasses import FrozenInstanceError
|
||||||
|
from datetime import UTC, datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import time_machine
|
||||||
|
|
||||||
|
from owncastsentry.types import (
|
||||||
|
_MAX_INSTANCE_TITLE_LENGTH,
|
||||||
|
_MAX_STREAM_TITLE_LENGTH,
|
||||||
|
_MAX_TAG_LENGTH,
|
||||||
|
UNKNOWN_STATUS_THRESHOLD,
|
||||||
|
AlreadySubscribedError,
|
||||||
|
InvalidApiResponseError,
|
||||||
|
InvalidOwncastInstanceError,
|
||||||
|
NotSubscribedError,
|
||||||
|
RoomSubscription,
|
||||||
|
StreamConfigObservation,
|
||||||
|
StreamState,
|
||||||
|
StreamStateObservation,
|
||||||
|
StreamStatus,
|
||||||
|
SubscriptionError,
|
||||||
|
UpdateResult,
|
||||||
|
_truncate,
|
||||||
|
format_status_since,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTruncate:
|
||||||
|
"""Text truncation to a maximum length."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("text", "max_length", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param("hello", 10, "hello", id="under-limit"),
|
||||||
|
pytest.param("hello", 5, "hello", id="exact-limit"),
|
||||||
|
pytest.param("hello world", 5, "hello", id="over-limit"),
|
||||||
|
pytest.param("", 5, "", id="empty-string"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_truncates(self, text: str, max_length: int, expected: str) -> None:
|
||||||
|
"""Return text unchanged within the limit and truncate over-limit text."""
|
||||||
|
assert _truncate(text, max_length) == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamStateStatus:
|
||||||
|
"""Stream status derivation from state fields."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("failure_counter", "online", "expected"),
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
UNKNOWN_STATUS_THRESHOLD + 1,
|
||||||
|
False,
|
||||||
|
StreamStatus.UNKNOWN,
|
||||||
|
id="above-threshold-offline-returns-unknown",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
UNKNOWN_STATUS_THRESHOLD + 1,
|
||||||
|
True,
|
||||||
|
StreamStatus.UNKNOWN,
|
||||||
|
id="above-threshold-online-returns-unknown",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
0,
|
||||||
|
True,
|
||||||
|
StreamStatus.ONLINE,
|
||||||
|
id="zero-failures-online-returns-online",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
0,
|
||||||
|
False,
|
||||||
|
StreamStatus.OFFLINE,
|
||||||
|
id="zero-failures-offline-returns-offline",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
UNKNOWN_STATUS_THRESHOLD,
|
||||||
|
True,
|
||||||
|
StreamStatus.ONLINE,
|
||||||
|
id="at-threshold-online-returns-online",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
UNKNOWN_STATUS_THRESHOLD,
|
||||||
|
False,
|
||||||
|
StreamStatus.OFFLINE,
|
||||||
|
id="at-threshold-offline-returns-offline",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_status(
|
||||||
|
self,
|
||||||
|
failure_counter: int,
|
||||||
|
online: object,
|
||||||
|
expected: StreamStatus,
|
||||||
|
) -> None:
|
||||||
|
"""Return the correct status based on failure counter and online state."""
|
||||||
|
state = StreamState(
|
||||||
|
domain="example.com",
|
||||||
|
failure_counter=failure_counter,
|
||||||
|
online=online is True,
|
||||||
|
)
|
||||||
|
assert state.status is expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestFormatStatusSince:
|
||||||
|
"""Status timestamp formatting."""
|
||||||
|
|
||||||
|
def test_formats_as_utc_iso_seconds(self) -> None:
|
||||||
|
"""Normalize aware datetimes to UTC with second precision."""
|
||||||
|
timestamp = datetime(
|
||||||
|
2026,
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
12,
|
||||||
|
34,
|
||||||
|
56,
|
||||||
|
123456,
|
||||||
|
tzinfo=timezone(timedelta(hours=-5)),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert format_status_since(timestamp) == "2026-01-01T17:34:56+00:00"
|
||||||
|
|
||||||
|
def test_treats_naive_as_utc(self) -> None:
|
||||||
|
"""Format naive datetimes as UTC."""
|
||||||
|
timestamp = datetime.fromisoformat("2026-01-01T12:34:56.123456")
|
||||||
|
|
||||||
|
assert format_status_since(timestamp) == "2026-01-01T12:34:56+00:00"
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamStateObservationFromApiResponse:
|
||||||
|
"""StreamStateObservation construction from an API response dictionary."""
|
||||||
|
|
||||||
|
def test_typical_response(self) -> None:
|
||||||
|
"""Populate API-derived fields from a complete status response."""
|
||||||
|
observed_at = datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC)
|
||||||
|
response = {
|
||||||
|
"streamTitle": "My Stream",
|
||||||
|
"online": True,
|
||||||
|
}
|
||||||
|
with time_machine.travel(observed_at, tick=False):
|
||||||
|
observation = StreamStateObservation.from_api_response(
|
||||||
|
response, "example.com"
|
||||||
|
)
|
||||||
|
assert observation.domain == "example.com"
|
||||||
|
assert observation.title == "My Stream"
|
||||||
|
assert observation.online is True
|
||||||
|
assert observation.observed_at == "2026-01-01T00:00:01+00:00"
|
||||||
|
|
||||||
|
def test_missing_required_field_raises(self) -> None:
|
||||||
|
"""Reject API responses without required stream state fields."""
|
||||||
|
with pytest.raises(InvalidApiResponseError):
|
||||||
|
StreamStateObservation.from_api_response({}, "bare.example.com")
|
||||||
|
|
||||||
|
def test_offline_response(self) -> None:
|
||||||
|
"""Populate offline state from a status response."""
|
||||||
|
observed_at = datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC)
|
||||||
|
response = {
|
||||||
|
"streamTitle": "Offline Stream",
|
||||||
|
"online": False,
|
||||||
|
}
|
||||||
|
with time_machine.travel(observed_at, tick=False):
|
||||||
|
observation = StreamStateObservation.from_api_response(
|
||||||
|
response, "example.com"
|
||||||
|
)
|
||||||
|
assert observation.online is False
|
||||||
|
assert observation.observed_at == "2026-01-01T00:00:01+00:00"
|
||||||
|
|
||||||
|
def test_title_truncation(self) -> None:
|
||||||
|
"""Truncate the stream title to _MAX_STREAM_TITLE_LENGTH."""
|
||||||
|
observed_at = datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC)
|
||||||
|
long_title = "A" * (_MAX_STREAM_TITLE_LENGTH + 50)
|
||||||
|
response = {
|
||||||
|
"streamTitle": long_title,
|
||||||
|
"online": True,
|
||||||
|
}
|
||||||
|
with time_machine.travel(observed_at, tick=False):
|
||||||
|
observation = StreamStateObservation.from_api_response(
|
||||||
|
response, "example.com"
|
||||||
|
)
|
||||||
|
assert len(observation.title) == _MAX_STREAM_TITLE_LENGTH
|
||||||
|
assert observation.title == "A" * _MAX_STREAM_TITLE_LENGTH
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("field", "value"),
|
||||||
|
[
|
||||||
|
pytest.param("streamTitle", 123, id="title-not-string"),
|
||||||
|
pytest.param("online", "true", id="online-not-bool"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_field_type_raises(self, field: str, value: object) -> None:
|
||||||
|
"""Reject stream state responses with malformed field types."""
|
||||||
|
response: dict[str, object] = {
|
||||||
|
"streamTitle": "My Stream",
|
||||||
|
"online": True,
|
||||||
|
}
|
||||||
|
response[field] = value
|
||||||
|
|
||||||
|
with pytest.raises(InvalidApiResponseError):
|
||||||
|
StreamStateObservation.from_api_response(response, "example.com")
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamStateFromDbRow:
|
||||||
|
"""StreamState construction from a database row dictionary."""
|
||||||
|
|
||||||
|
def test_typical_row(self) -> None:
|
||||||
|
"""Populate all fields from a complete database row."""
|
||||||
|
row = {
|
||||||
|
"domain": "example.com",
|
||||||
|
"name": "Test Instance",
|
||||||
|
"title": "Live Now",
|
||||||
|
"online": True,
|
||||||
|
"status_since": "2026-01-01T00:00:00+00:00",
|
||||||
|
"failure_counter": 3,
|
||||||
|
}
|
||||||
|
state = StreamState.from_db_row(row)
|
||||||
|
assert state.domain == "example.com"
|
||||||
|
assert state.name == "Test Instance"
|
||||||
|
assert state.title == "Live Now"
|
||||||
|
assert state.online is True
|
||||||
|
assert state.status_since == "2026-01-01T00:00:00+00:00"
|
||||||
|
assert state.failure_counter == 3
|
||||||
|
|
||||||
|
def test_row_with_none_optional_fields(self) -> None:
|
||||||
|
"""Accept None for optional fields in a database row."""
|
||||||
|
row = {
|
||||||
|
"domain": "example.com",
|
||||||
|
"name": None,
|
||||||
|
"title": None,
|
||||||
|
"online": False,
|
||||||
|
"status_since": None,
|
||||||
|
"failure_counter": 0,
|
||||||
|
}
|
||||||
|
state = StreamState.from_db_row(row)
|
||||||
|
assert state.domain == "example.com"
|
||||||
|
assert state.name is None
|
||||||
|
assert state.title is None
|
||||||
|
assert state.online is False
|
||||||
|
assert state.status_since is None
|
||||||
|
assert state.failure_counter == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestStreamConfigObservationFromApiResponse:
|
||||||
|
"""StreamConfigObservation construction from an API response dictionary."""
|
||||||
|
|
||||||
|
def test_typical_response(self) -> None:
|
||||||
|
"""Populate name and tags from a complete API response."""
|
||||||
|
response = {"name": "My Instance", "tags": ["gaming", "music"]}
|
||||||
|
observed_at = datetime(2026, 1, 1, 0, 0, 1, tzinfo=UTC)
|
||||||
|
with time_machine.travel(observed_at, tick=False):
|
||||||
|
config = StreamConfigObservation.from_api_response(response)
|
||||||
|
assert config.name == "My Instance"
|
||||||
|
assert config.tags == ("gaming", "music")
|
||||||
|
assert config.observed_at == "2026-01-01T00:00:01+00:00"
|
||||||
|
|
||||||
|
def test_missing_keys_defaults(self) -> None:
|
||||||
|
"""Use defaults when name and tags keys are missing."""
|
||||||
|
config = StreamConfigObservation.from_api_response({})
|
||||||
|
assert config.name == ""
|
||||||
|
assert config.tags == ()
|
||||||
|
|
||||||
|
def test_name_truncation(self) -> None:
|
||||||
|
"""Truncate the instance name to _MAX_INSTANCE_TITLE_LENGTH."""
|
||||||
|
long_name = "B" * (_MAX_INSTANCE_TITLE_LENGTH + 50)
|
||||||
|
response = {"name": long_name, "tags": []}
|
||||||
|
config = StreamConfigObservation.from_api_response(response)
|
||||||
|
assert len(config.name) == _MAX_INSTANCE_TITLE_LENGTH
|
||||||
|
assert config.name == "B" * _MAX_INSTANCE_TITLE_LENGTH
|
||||||
|
|
||||||
|
def test_tag_truncation(self) -> None:
|
||||||
|
"""Truncate each tag to _MAX_TAG_LENGTH."""
|
||||||
|
long_tag = "C" * (_MAX_TAG_LENGTH + 10)
|
||||||
|
response = {"name": "", "tags": [long_tag, "short"]}
|
||||||
|
config = StreamConfigObservation.from_api_response(response)
|
||||||
|
assert len(config.tags[0]) == _MAX_TAG_LENGTH
|
||||||
|
assert config.tags[0] == "C" * _MAX_TAG_LENGTH
|
||||||
|
assert config.tags[1] == "short"
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("field", "value"),
|
||||||
|
[
|
||||||
|
pytest.param("name", None, id="name-not-string"),
|
||||||
|
pytest.param("tags", "gaming", id="tags-not-list"),
|
||||||
|
pytest.param("tags", ["gaming", 123], id="tag-not-string"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_field_type_raises(self, field: str, value: object) -> None:
|
||||||
|
"""Reject stream config responses with malformed field types."""
|
||||||
|
response: dict[str, object] = {"name": "My Instance", "tags": ["gaming"]}
|
||||||
|
response[field] = value
|
||||||
|
|
||||||
|
with pytest.raises(InvalidApiResponseError):
|
||||||
|
StreamConfigObservation.from_api_response(response)
|
||||||
|
|
||||||
|
|
||||||
|
class TestValueTypeImmutability:
|
||||||
|
"""Dataclass value containers are immutable snapshots."""
|
||||||
|
|
||||||
|
def test_stream_state_is_immutable(self) -> None:
|
||||||
|
"""StreamState cannot be mutated in place."""
|
||||||
|
state = StreamState(domain="stream.example")
|
||||||
|
with pytest.raises(FrozenInstanceError):
|
||||||
|
state.title = "Changed" # type: ignore[misc]
|
||||||
|
|
||||||
|
def test_stream_observation_is_immutable(self) -> None:
|
||||||
|
"""StreamStateObservation cannot be mutated in place."""
|
||||||
|
observation = StreamStateObservation(
|
||||||
|
domain="stream.example",
|
||||||
|
)
|
||||||
|
with pytest.raises(FrozenInstanceError):
|
||||||
|
observation.title = "Changed" # type: ignore[misc]
|
||||||
|
|
||||||
|
def test_stream_config_is_immutable(self) -> None:
|
||||||
|
"""StreamConfigObservation cannot be mutated in place."""
|
||||||
|
config = StreamConfigObservation(name="Stream")
|
||||||
|
with pytest.raises(FrozenInstanceError):
|
||||||
|
config.name = "Changed" # type: ignore[misc]
|
||||||
|
|
||||||
|
def test_stream_config_tags_are_immutable(self) -> None:
|
||||||
|
"""StreamConfigObservation tags are stored in an immutable tuple."""
|
||||||
|
config = StreamConfigObservation(name="Stream", tags=("gaming",))
|
||||||
|
assert config.tags == ("gaming",)
|
||||||
|
|
||||||
|
def test_update_result_is_immutable(self) -> None:
|
||||||
|
"""UpdateResult cannot be mutated in place."""
|
||||||
|
result = UpdateResult(total_streams=1, successful_checks=1, failed_checks=0)
|
||||||
|
with pytest.raises(FrozenInstanceError):
|
||||||
|
result.failed_checks = 1 # type: ignore[misc]
|
||||||
|
|
||||||
|
|
||||||
|
class TestSubscriptionTypes:
|
||||||
|
"""Subscription display containers and domain error hierarchy."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"error_cls",
|
||||||
|
[
|
||||||
|
pytest.param(InvalidOwncastInstanceError, id="invalid-instance"),
|
||||||
|
pytest.param(AlreadySubscribedError, id="already-subscribed"),
|
||||||
|
pytest.param(NotSubscribedError, id="not-subscribed"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_errors_subclass_subscription_error(
|
||||||
|
self, error_cls: type[Exception]
|
||||||
|
) -> None:
|
||||||
|
"""Every subscription domain error subclasses SubscriptionError."""
|
||||||
|
assert issubclass(error_cls, SubscriptionError)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"error",
|
||||||
|
[
|
||||||
|
pytest.param(
|
||||||
|
InvalidOwncastInstanceError("bad.example"),
|
||||||
|
id="invalid-instance",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
AlreadySubscribedError("dupe.example"),
|
||||||
|
id="already-subscribed",
|
||||||
|
),
|
||||||
|
pytest.param(
|
||||||
|
NotSubscribedError("missing.example"),
|
||||||
|
id="not-subscribed",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_errors_store_domain(self, error: SubscriptionError) -> None:
|
||||||
|
"""Subscription domain errors expose the stream domain that failed."""
|
||||||
|
assert error.domain in str(error)
|
||||||
|
|
||||||
|
def test_room_subscription_is_immutable(self) -> None:
|
||||||
|
"""RoomSubscription is an immutable stream display snapshot."""
|
||||||
|
state = StreamState(domain="stream.example")
|
||||||
|
subscription = RoomSubscription(domain="stream.example", stream_state=state)
|
||||||
|
with pytest.raises(FrozenInstanceError):
|
||||||
|
subscription.domain = "other.example" # type: ignore[misc]
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
# Copyright 2026 Logan Fick
|
|
||||||
#
|
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
# you may not use this file except in compliance with the License.
|
|
||||||
# You may obtain a copy of the License at
|
|
||||||
#
|
|
||||||
# http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
#
|
|
||||||
# Unless required by applicable law or agreed to in writing, software
|
|
||||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
# See the License for the specific language governing permissions and
|
|
||||||
# limitations under the License.
|
|
||||||
|
|
||||||
"""Tests for utility functions and constants."""
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from owncastsentry.utils import (
|
|
||||||
domainify,
|
|
||||||
escape_markdown,
|
|
||||||
sanitize_for_markdown,
|
|
||||||
sanitize_for_plain_text,
|
|
||||||
should_query_stream,
|
|
||||||
truncate,
|
|
||||||
user_agent,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestUserAgent:
|
|
||||||
"""User-Agent header construction."""
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("version", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param(
|
|
||||||
"1.2.3",
|
|
||||||
"OwncastSentry/1.2.3 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)",
|
|
||||||
id="semver",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"0.0.0",
|
|
||||||
"OwncastSentry/0.0.0 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)",
|
|
||||||
id="zeroed",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"1.1.1.dev10+gf0146d061.d20260313",
|
|
||||||
"OwncastSentry/1.1.1.dev10+gf0146d061.d20260313 (bot; +https://git.logal.dev/LogalDeveloper/OwncastSentry)",
|
|
||||||
id="dev-version",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_user_agent(self, version: str, expected: str) -> None:
|
|
||||||
"""Build a correctly formatted User-Agent header."""
|
|
||||||
assert user_agent(version) == expected
|
|
||||||
|
|
||||||
|
|
||||||
class TestShouldQueryStream:
|
|
||||||
"""Progressive backoff logic for stream polling."""
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("counter", "expected"),
|
|
||||||
[
|
|
||||||
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(100, False, id="counter-100-skip-not-mod-15"),
|
|
||||||
pytest.param(105, True, id="counter-105-query-mod-15"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
class TestDomainify:
|
|
||||||
"""Domain extraction and sanitization from user input."""
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("input_url", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param("example.com", "example.com", id="bare-domain"),
|
|
||||||
pytest.param("https://example.com", "example.com", id="https-url"),
|
|
||||||
pytest.param("http://example.com", "example.com", id="http-url"),
|
|
||||||
pytest.param("https://example.com:8080", "example.com", id="url-with-port"),
|
|
||||||
pytest.param(
|
|
||||||
"https://example.com/path/to/page",
|
|
||||||
"example.com",
|
|
||||||
id="url-with-path",
|
|
||||||
),
|
|
||||||
pytest.param(
|
|
||||||
"user@stream.logal.dev",
|
|
||||||
"stream.logal.dev",
|
|
||||||
id="email-style",
|
|
||||||
),
|
|
||||||
pytest.param("EXAMPLE.COM", "example.com", id="uppercase"),
|
|
||||||
pytest.param("exam!ple.com", "example.com", id="special-chars-stripped"),
|
|
||||||
pytest.param(".example.com.", "example.com", id="leading-trailing-dots"),
|
|
||||||
pytest.param("-example.com-", "example.com", id="leading-trailing-hyphens"),
|
|
||||||
pytest.param(
|
|
||||||
"sub.domain.example.com",
|
|
||||||
"sub.domain.example.com",
|
|
||||||
id="subdomain",
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_extracts_domain(self, input_url: str, expected: str) -> None:
|
|
||||||
"""Extract and sanitize the domain from various input formats."""
|
|
||||||
assert domainify(input_url) == expected
|
|
||||||
|
|
||||||
|
|
||||||
class TestTruncate:
|
|
||||||
"""Text truncation to a maximum length."""
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("text", "max_length", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param("hello", 10, "hello", id="under-limit"),
|
|
||||||
pytest.param("hello", 5, "hello", id="exact-limit"),
|
|
||||||
pytest.param("hello world", 5, "hello", id="over-limit"),
|
|
||||||
pytest.param("", 5, "", id="empty-string"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_truncates(self, text: str, max_length: int, expected: str) -> None:
|
|
||||||
"""Truncate text that exceeds the maximum length."""
|
|
||||||
assert truncate(text, max_length) == expected
|
|
||||||
|
|
||||||
|
|
||||||
class TestEscapeMarkdown:
|
|
||||||
"""Markdown special character escaping."""
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("input_text", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param("hello", "hello", id="plain-text-unchanged"),
|
|
||||||
pytest.param("*bold*", "\\*bold\\*", id="asterisks"),
|
|
||||||
pytest.param("_italic_", "\\_italic\\_", id="underscores"),
|
|
||||||
pytest.param("[link](url)", "\\[link\\]\\(url\\)", id="link-syntax"),
|
|
||||||
pytest.param("`code`", "\\`code\\`", id="backticks"),
|
|
||||||
pytest.param("# heading", "\\# heading", id="heading"),
|
|
||||||
pytest.param("> quote", "\\> quote", id="blockquote"),
|
|
||||||
pytest.param("<html>", "\\<html\\>", id="angle-brackets"),
|
|
||||||
pytest.param("a & b", "a \\& b", id="ampersand"),
|
|
||||||
pytest.param("a\\b", "a\\\\b", id="backslash"),
|
|
||||||
pytest.param("", "", id="empty-string"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_escapes_special_chars(self, input_text: str, expected: str) -> None:
|
|
||||||
"""Escape the given Markdown special character."""
|
|
||||||
assert escape_markdown(input_text) == expected
|
|
||||||
|
|
||||||
|
|
||||||
class TestSanitizeForPlainText:
|
|
||||||
"""Plain text sanitization for notifications."""
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
("input_text", "expected"),
|
|
||||||
[
|
|
||||||
pytest.param("hello world", "hello world", id="plain-text"),
|
|
||||||
pytest.param("line1\nline2", "line1 line2", id="newline-removed"),
|
|
||||||
pytest.param("line1\rline2", "line1 line2", id="carriage-return"),
|
|
||||||
pytest.param("line1\r\nline2", "line1 line2", id="crlf-removed"),
|
|
||||||
pytest.param(
|
|
||||||
"too many spaces", "too many spaces", id="spaces-collapsed"
|
|
||||||
),
|
|
||||||
pytest.param("", "", id="empty-string"),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
def test_sanitizes(self, input_text: str, expected: str) -> None:
|
|
||||||
"""Sanitize the text for safe plain-text rendering."""
|
|
||||||
assert sanitize_for_plain_text(input_text) == expected
|
|
||||||
|
|
||||||
|
|
||||||
class TestSanitizeForMarkdown:
|
|
||||||
"""Markdown sanitization combining newline removal and escaping."""
|
|
||||||
|
|
||||||
def test_removes_newlines_and_escapes(self) -> None:
|
|
||||||
"""Remove newlines and escape Markdown special characters."""
|
|
||||||
result = sanitize_for_markdown("*bold*\nnew line")
|
|
||||||
assert result == "\\*bold\\* new line"
|
|
||||||
|
|
||||||
def test_empty_string(self) -> None:
|
|
||||||
"""Return empty string unchanged."""
|
|
||||||
assert sanitize_for_markdown("") == ""
|
|
||||||
@@ -112,6 +112,44 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ast-serialize"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/e2/1f/50f241d4e01fe75f4bba6a209edd4047c4b26acf70992ff885fd161f79cb/ast_serialize-0.4.0.tar.gz", hash = "sha256:74e4e634ab82d1466acf0be27043178570b98ebeaa3165f9240a6fad4c286471", size = 60687, upload-time = "2026-05-14T22:44:38.251Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/85/232631c59b5ca7152c08f026e9a46f47d852298acff74edd04a1fc1d0005/ast_serialize-0.4.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a6f26937ce0293aafbece0e39019e020369a5a70486ff4088227f0cc888844a9", size = 1182685, upload-time = "2026-05-14T22:43:40.205Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5d/5e/4838d4d3ddc4425555601467d4e2a565e4340899e45feee4e32c80fbc911/ast_serialize-0.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:074032142777e3e6091977dc3c5146a8ca58ae6825b7f64e9a0b604153ddabd8", size = 1173113, upload-time = "2026-05-14T22:43:41.937Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/22/fc/d622b19fc1c79a62028ec17f4ad4323177af25b174d32b07c84d61ef9d47/ast_serialize-0.4.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:404f3462b4532e13a70b8849bba241dbd82e30043ff58d98c7e762fd925b116a", size = 1234117, upload-time = "2026-05-14T22:43:43.977Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/b5/72f8c8659da0b64562e6d97f852d5c2022c74577df27c922e1e7065039ce/ast_serialize-0.4.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:97c55336e16f5c4ca2bde7be94cca4b8f7d665d64f7008925a82e02707ba14ac", size = 1231703, upload-time = "2026-05-14T22:43:46.064Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/98/ccc51ee4f90f97a1ed0a0848bd4c9d77a80969849db8a262b7d2970a6a15/ast_serialize-0.4.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:732b4ef76adcb0f298a7d18c4558336d83b1384f9ae0c7eaa1dc8d031b0a4390", size = 1441574, upload-time = "2026-05-14T22:43:47.784Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/ce/668c4efe79e09c9cc97a4d0a1c29e61fe6f78857fe1e57c086772af55f89/ast_serialize-0.4.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b3db87c4772097c0782250bcd550d66b1189a8c889793c7bcf153f4fee70005c", size = 1254040, upload-time = "2026-05-14T22:43:49.879Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/be/38b27bc2909b7236939801ca9f0d97cdc6198da4f435a81658e0db506fdb/ast_serialize-0.4.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:43729a5e369ebbe7750635c0c206bc616fcd36e703cb9c4497d6b4df0291ee64", size = 1257847, upload-time = "2026-05-14T22:43:51.607Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/68/df/360ebccc361235c167a8be2a0476870cb9ef44c42413bf1289b885684052/ast_serialize-0.4.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:91d3786f3929786cdc4eeedfd110abb4603e7f6c1390c5af398f333a947b742d", size = 1298683, upload-time = "2026-05-14T22:43:53.606Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/5c/7d5e0b4d47aafa1600c19e3670f962f81a9bf3da1bc25a1382529a447cf3/ast_serialize-0.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7fba7315fd4bd87cb5560792709f6e66e0606402d362c0a38dd32dfb66ba6066", size = 1409438, upload-time = "2026-05-14T22:43:55.316Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/3d/8875b2f1af3ec1539b88ff193dfbfa5573084ef7fcab27ea4cd09b6dc829/ast_serialize-0.4.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:4db9769d57deb5545ce56ebbbbe3436dcc0ae2688ce14c295cd14e106624ece7", size = 1507922, upload-time = "2026-05-14T22:43:56.959Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/c3/5ec6927eb493ece7ba64263cdc556be889e0c62a013b1851bbe674a0dcda/ast_serialize-0.4.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:dcd04f85a29deb80400e8987cfaceb9907140f763453cbffdbd6ff36f1b32c12", size = 1502817, upload-time = "2026-05-14T22:43:59.081Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9d/c8/40cb818a08396b1f34d6189c0c42aec917dd331e11fb7c3b870cc61b795a/ast_serialize-0.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:905fc11940831454d93589bd7ce2acb6a5eb01c2936156f751d2a21087c98cd3", size = 1454318, upload-time = "2026-05-14T22:44:01.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/d5/d51494b60cc52f4792be5ddc951631cddb17a2990154634549abdbdbb5bf/ast_serialize-0.4.0-cp314-cp314t-win32.whl", hash = "sha256:3bdde2c4570143791f636aed4e3ef868f5b46eb90a18f8d5c41dd045aab08bef", size = 1060098, upload-time = "2026-05-14T22:44:03.265Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7a/c9/b0086257c79ff95743a3621448a01fc71b234ae359d3d54cda383aa43939/ast_serialize-0.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6551d55b8607b97a7755683d743200b398c61a0b71a11b7f00c89c335a11d0f4", size = 1101015, upload-time = "2026-05-14T22:44:05.055Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/6d/3dfddef4990fda47745af6615a3e51c4de711eda56c3a8072a0d8b6181c7/ast_serialize-0.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:7234ff086cb152ea2a3b7ef895b5ebeb6d80779df049d5c6431c8e3536d5b03c", size = 1074495, upload-time = "2026-05-14T22:44:07.186Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/d5/044c5f995ef75807a0effb56fc288cfdedeeb571222450fb6f7d94fd52f1/ast_serialize-0.4.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:dcded5056d9f3d201df7833082c07ebcbc566ffc3d4105c9fc9fe278fa086ecb", size = 1189800, upload-time = "2026-05-14T22:44:09.333Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/5a/52163557789d59a8197c10912ab4a1791c9143731ba0e3d9283ac0791db6/ast_serialize-0.4.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bd50d201098aae0d202805fe9606c0545492f69a3ec4403337e32c54ad29fc41", size = 1181713, upload-time = "2026-05-14T22:44:11.286Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/c3/678ce3b6cb594b01c361da87f6c5679d26c1dae1583a082a8cd190e7232e/ast_serialize-0.4.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6615b39cd747967c3aabe68bf3f5f26748e823cc6b474ddc1510ed188a824149", size = 1243258, upload-time = "2026-05-14T22:44:13.345Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3d/dd/4810fbeb81c47b7e4e65db15ca65c71330efc59b460bd10c12338dc6012e/ast_serialize-0.4.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:91362c0a9fdf1c344b7f50a5b0508b11a0732102998fbd754a191f7187e77031", size = 1239226, upload-time = "2026-05-14T22:44:15.811Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/28/38/13a88d90b664c009ed208346ec2ed248b0ab2cb0b582ae467acaa7f44fa4/ast_serialize-0.4.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:70d9c5d527bbfa69bd3c7d17dac11fb6781e36186a434a06d7d5892e0b2f88f9", size = 1448867, upload-time = "2026-05-14T22:44:17.99Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4c/19/a069dba1a634b703bf07fb49df8f7e3c04e9ba8ef3f0d9f4495f72630f92/ast_serialize-0.4.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4738790cf54d8b416de992b87ee567056980bc82134d52458bd4985f389d1658", size = 1264135, upload-time = "2026-05-14T22:44:19.8Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/4c/76ec4279fecd7e78b60c3c99321f944c43cd11e5ff09c952746f5f9c0f4c/ast_serialize-0.4.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:faa008dccfcb793ae9101325e4d6d026caaa5d845c2182f03749c759834b0a3a", size = 1269060, upload-time = "2026-05-14T22:44:21.894Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/33/c5/9230ef7481e5cb63b93a1f7738e959586202b081caf32b8bc5d9f673ef56/ast_serialize-0.4.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1c5245228e65d38cb48e1251f0ca71b0fa417e527141491e8c92f740e8e2d121", size = 1309654, upload-time = "2026-05-14T22:44:23.725Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b9/54/7d7397528d181ad68e476e0c81aa3ceff7d1f1b5c7fa958d6be28628ef16/ast_serialize-0.4.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:8f5153e9c44a02e61f4042c5f9249d2e8a759773d621a0b2f445a899e536e181", size = 1418855, upload-time = "2026-05-14T22:44:25.415Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b8/8f/87d6428adaa0986b817404f09329b64f8d2614cfe061ebf4951b4a7e0d19/ast_serialize-0.4.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:1e1fb90def261f6a0db885876f7e1a49ad2dbac38ad9f2f62dba2f9543af16e7", size = 1516040, upload-time = "2026-05-14T22:44:27.535Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b5/bb/5aaa41a21314c8b0d6dee54867b16535682c6660dd28cac64dba1380062d/ast_serialize-0.4.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cf2ff7b654c8e95143e20f5d75878cbb78b65b928b26c4d58ef71cdba9d6d981", size = 1511450, upload-time = "2026-05-14T22:44:29.522Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/87/16/cc729b5bb4b21da99db1379266cc367512e82ba10f9b3300a6f3e9941325/ast_serialize-0.4.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:90fc5c0d35a22f1a92dd33635508626d50f8fc64deb897c23e78e666a60804c9", size = 1463654, upload-time = "2026-05-14T22:44:31.265Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/43/97/7198321b0244d011093387b41affea934d58bda08d59a2adfde72976b6c4/ast_serialize-0.4.0-cp39-abi3-win32.whl", hash = "sha256:9ecd6a1fc1b86f1f4e8ae206759b6319c10019706b3496b01b54d02b9b2cd918", size = 1068636, upload-time = "2026-05-14T22:44:33.189Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/10/09/3b868f6d8df4bbe452903a5e0e039ebcec9ea0045f1a77951546205097e8/ast_serialize-0.4.0-cp39-abi3-win_amd64.whl", hash = "sha256:79c8d015c771c8bfdb1208003b227b27c40034790a2c29c09f2317a041825ce2", size = 1107137, upload-time = "2026-05-14T22:44:35.304Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fd/78/9387dffccdc55a12734f83aaccc4a987404a217a2a12a1920d8d4585950b/ast_serialize-0.4.0-cp39-abi3-win_arm64.whl", hash = "sha256:1026f565a7ab846337c630909089b3346a2fe417bf1552b1581ab01852137407", size = 1079199, upload-time = "2026-05-14T22:44:36.816Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "asyncpg"
|
name = "asyncpg"
|
||||||
version = "0.31.0"
|
version = "0.31.0"
|
||||||
@@ -341,85 +379,85 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "coverage"
|
name = "coverage"
|
||||||
version = "7.13.5"
|
version = "7.14.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/23/7f/d0720730a397a999ffc0fd3f5bebef347338e3a47b727da66fbb228e2ff2/coverage-7.14.0.tar.gz", hash = "sha256:057a6af2f160a85384cde4ab36f0d2777bae1057bae255f95413cdd382aa5c74", size = 919489, upload-time = "2026-05-10T18:02:31.397Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" },
|
{ url = "https://files.pythonhosted.org/packages/1c/18/b9a6586d73992807c26f9a5f274131be3d76b56b18a82b9392e2a25d2e45/coverage-7.14.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9aed9fa983514ca032790f3fe0d1c0e42ca7e16b42432af1706b50a9a46bef5d", size = 220036, upload-time = "2026-05-10T18:01:33.057Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" },
|
{ url = "https://files.pythonhosted.org/packages/f3/9b/4165a1d56ddc302a0e2d518fd9d412a4fd0b57562618c78c5f21c57194f5/coverage-7.14.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ba3b8390db29296dbbf49e91b6fe08f990743a90c8f447ba4c2ffc29670dfa63", size = 220368, upload-time = "2026-05-10T18:01:34.705Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" },
|
{ url = "https://files.pythonhosted.org/packages/69/aa/c12e52a5ba148d9995229d557e3be6e554fe469addc0e9241b2f0956d8ea/coverage-7.14.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3a5d8e876dfa2f102e970b183863d6dedd023d3c0eeca1fe7a9787bc5f28b212", size = 251417, upload-time = "2026-05-10T18:01:36.949Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" },
|
{ url = "https://files.pythonhosted.org/packages/d7/51/ec641c26e6dca1b25a7d2035ba6ecb7c884ef1a100a9e42fbe4ce4405139/coverage-7.14.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5ebb8f4614a3787d567e610bbfdf96a4798dd69a1afb1bd8ad228d4111fe6ff3", size = 253924, upload-time = "2026-05-10T18:01:38.985Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" },
|
{ url = "https://files.pythonhosted.org/packages/33/c4/59c3de0bd1b538824173fd518fed51c1ce740ca5ed68e74545983f4053a9/coverage-7.14.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b9bf47223dd8db3d4c4b2e443b02bace480d428f0822c3f991600448a176c97", size = 255269, upload-time = "2026-05-10T18:01:40.957Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" },
|
{ url = "https://files.pythonhosted.org/packages/7b/a9/36dfa153a62040296f6e7febfdb20a5720622f6ef5a81a41e8237b9a5344/coverage-7.14.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3485a836550b303d006d57cc06e3d5afaabc642c77050b7c985a97b13e3776b8", size = 257583, upload-time = "2026-05-10T18:01:42.607Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" },
|
{ url = "https://files.pythonhosted.org/packages/26/7b/cc2c048d4114d9ab1c2409e9ee365e5ae10736df6dffcfc9444effa6c708/coverage-7.14.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3e7e88110bae996d199d1693ca8ec3fd52441d426401ae963437598667b4c5eb", size = 251434, upload-time = "2026-05-10T18:01:44.537Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" },
|
{ url = "https://files.pythonhosted.org/packages/ee/df/6770eaa576e604575e9a78055313250faef5faa84bd6f71a39fece519c43/coverage-7.14.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:15228a6800ce7bdf1b74800595e56db7138cecb338fdbf044806e10dcf182dfe", size = 253280, upload-time = "2026-05-10T18:01:46.175Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" },
|
{ url = "https://files.pythonhosted.org/packages/ad/9e/1c0264514a3f98259a6d64765a397b2c8373e3ba59ee722a4802d3ec0c61/coverage-7.14.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9d26ac7f5398bafc5b57421ad994e8a4749e8a7a0e62d05ec7d53014d5963bfa", size = 251241, upload-time = "2026-05-10T18:01:48.732Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" },
|
{ url = "https://files.pythonhosted.org/packages/64/16/4efdf3e3c4079cdbf0ece56a2fea872df9e8a3e15a13a0af4400e1075944/coverage-7.14.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2fb73254ff43c911c967a899e1359bc5049b4b115d6e8fbdde4937d0a2246cd5", size = 255516, upload-time = "2026-05-10T18:01:50.819Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" },
|
{ url = "https://files.pythonhosted.org/packages/93/69/b1de96346603881b3d1bc8d6447c83200e1c9700ffbaff926ba01ff5724c/coverage-7.14.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:454a380af72c6adada298ed270d38c7a391288198dbfb8467f786f588751a90c", size = 251059, upload-time = "2026-05-10T18:01:52.773Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" },
|
{ url = "https://files.pythonhosted.org/packages/a4/66/2881853e0363a5e0a724d1103e53650795367471b6afb234f8b49e713bc6/coverage-7.14.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:65c86fb646d2bd2972e96bd1a8b45817ed907cee68655d6295fe7ec031d04cca", size = 252716, upload-time = "2026-05-10T18:01:54.506Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" },
|
{ url = "https://files.pythonhosted.org/packages/55/5c/0d3305d002c41dcde873dbe456491e663dc55152ca526b630b5c47efd62f/coverage-7.14.0-cp314-cp314-win32.whl", hash = "sha256:6a6516b02a6101398e19a3f44820f69bab2590697f7def4331f668b14adaf828", size = 222788, upload-time = "2026-05-10T18:01:56.487Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" },
|
{ url = "https://files.pythonhosted.org/packages/f9/58/6e1b8f52fdc3184b47dc5037f5070d83a3d11042db1594b02d2a44d786c8/coverage-7.14.0-cp314-cp314-win_amd64.whl", hash = "sha256:45e0f79d8351fa76e256716df91eab12890d32678b9590df7ae1042e4bd4cf5d", size = 223600, upload-time = "2026-05-10T18:01:58.497Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" },
|
{ url = "https://files.pythonhosted.org/packages/00/70/a18c408e674bc26281cadaedc7351f929bd2094e191e4b15271c30b084cc/coverage-7.14.0-cp314-cp314-win_arm64.whl", hash = "sha256:4b899594a8b2d81e5cc064a0d7f9cac2081fed91049456cae7676787e41549c9", size = 222168, upload-time = "2026-05-10T18:02:00.411Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" },
|
{ url = "https://files.pythonhosted.org/packages/3d/89/2681f071d238b62aff8dfc2ab44fc24cfdb38d1c01f391a80522ff5d3a16/coverage-7.14.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f580f8c80acd94ac72e863efe2cab791d8c38d153e0b463b92dfa000d5c84cd1", size = 220766, upload-time = "2026-05-10T18:02:02.313Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" },
|
{ url = "https://files.pythonhosted.org/packages/bd/c7/c987babafd9207ffa1995e1ef1f9b26762cf4963aa768a66b6f0501e4616/coverage-7.14.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a2bd259c442cd43c49b30fbafc51776eb19ea396faf159d26a83e6a0a5f13b0c", size = 221035, upload-time = "2026-05-10T18:02:04.017Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/e9/d6a5ac3b333088143d6fc877d398a9a674dc03124a2f776e131f03864823/coverage-7.14.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a706b908dfa85538863504c624b237a3cc34232bf403c057414ebfdb3b4d9f84", size = 262405, upload-time = "2026-05-10T18:02:05.915Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" },
|
{ url = "https://files.pythonhosted.org/packages/38/b1/e70838d29a7c08e22d44398a46db90815bbcbf28de06992bd9210d1a8d8e/coverage-7.14.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7333cd944ee4393b9b3d3c1b598c936d4fc8d70573a4c7dacfec5590dd50e436", size = 264530, upload-time = "2026-05-10T18:02:07.582Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" },
|
{ url = "https://files.pythonhosted.org/packages/6b/73/5c31ef97763288d03d9995152b96d5475b527c63d91c84b01caea894b83a/coverage-7.14.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f162bc9a15b82d947b02651b0c7e1609d6f7a8735ca330cfadec8481dd97d5a", size = 266932, upload-time = "2026-05-10T18:02:09.401Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" },
|
{ url = "https://files.pythonhosted.org/packages/e1/76/dd56d80f29c5f05b4d76f7e7c6d47cafacae017189c75c5759d24f9ff0cc/coverage-7.14.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:362cb78e01a5dc82009d88004cf60f2e6b6d6fcbfdec05b05af73b0abf40118f", size = 268062, upload-time = "2026-05-10T18:02:11.399Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" },
|
{ url = "https://files.pythonhosted.org/packages/6e/c7/27ba85cd5b95614f159ff93ebff1901584a8d192e2e5e24c4943a7453f59/coverage-7.14.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:acebd068fca5512c3a6fde9c045f901613478781a73f0e82b307b214daef23fb", size = 261504, upload-time = "2026-05-10T18:02:13.257Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" },
|
{ url = "https://files.pythonhosted.org/packages/13/2e/e8149f60ab5d5684c6eee881bdf34b127115cddbb958b196768dd9d63473/coverage-7.14.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:29fe3da551dface75deb2ccbf87b6b66e2e7ef38f6d89050b428be94afff3490", size = 264398, upload-time = "2026-05-10T18:02:15.063Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" },
|
{ url = "https://files.pythonhosted.org/packages/d9/7f/1261b025285323225f4b4abffa5a643649dfd67e25ddca7ebcbdea3b7cb3/coverage-7.14.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b4cc4fce8672fffcb09b0eafc167b396b3ba53c4a7230f54b7aaffbf6c835fa9", size = 262000, upload-time = "2026-05-10T18:02:16.756Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" },
|
{ url = "https://files.pythonhosted.org/packages/d3/dc/829c54f60b9d08389439c00f813c752781c496fc5788c78d8006db4b4f2b/coverage-7.14.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:5d4a51aad8ba8bdcd2b8bd8f03d4aca19693fa2327a3470e4718a25b03481020", size = 265732, upload-time = "2026-05-10T18:02:18.817Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" },
|
{ url = "https://files.pythonhosted.org/packages/ed/b0/70bd1419941652fa062689cba9c3eeafb8f5e6fbb890bce41c3bdda5dbd6/coverage-7.14.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:9f323af3e1e4f68b60b7b247e37b8515563a61375518fa59de1af48ba28a3db6", size = 260847, upload-time = "2026-05-10T18:02:20.528Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" },
|
{ url = "https://files.pythonhosted.org/packages/f2/73/be40b2390656c654d35ea0015ea7ba3d945769cf80790ad5e0bb2d56d2ba/coverage-7.14.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1a0abc7342ea9711c469dd8b821c6c311e6bc6aac1442e5fbd6b27fae0a8f3db", size = 263166, upload-time = "2026-05-10T18:02:22.337Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/55/4a643f712fcf7cf2881f8ec1e0ccb7b164aff3108f69b51801246c8799f2/coverage-7.14.0-cp314-cp314t-win32.whl", hash = "sha256:a9f864ef57b7172e2db87a096642dd51e179e085ab6b2c371c29e885f65c8fb2", size = 223573, upload-time = "2026-05-10T18:02:24.11Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" },
|
{ url = "https://files.pythonhosted.org/packages/27/96/3acae5da0953be042c0b4dea6d6789d2f080701c77b88e44d5bd41b9219b/coverage-7.14.0-cp314-cp314t-win_amd64.whl", hash = "sha256:29943e552fdc08e082eb51400fb2f58e118a83b5542bd06531214e084399b644", size = 224680, upload-time = "2026-05-10T18:02:25.896Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" },
|
{ url = "https://files.pythonhosted.org/packages/93/3d/6ab5d2dd8325d838737c6f8d83d62eb6230e0d70b87b51b57bbfd08fa767/coverage-7.14.0-cp314-cp314t-win_arm64.whl", hash = "sha256:742a73ea621953b012f2c4c2219b512180dd84489acf5b1596b0aafc55b9100b", size = 222703, upload-time = "2026-05-10T18:02:27.822Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" },
|
{ url = "https://files.pythonhosted.org/packages/61/e8/cb8e80d6f9f55b99588625062822bf946cf03ed06315df4bd8397f5632a1/coverage-7.14.0-py3-none-any.whl", hash = "sha256:8de5b61163aee3d05c8a2beab6f47913df7981dad1baf82c414d99158c286ab1", size = 211764, upload-time = "2026-05-10T18:02:29.538Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cryptography"
|
name = "cryptography"
|
||||||
version = "47.0.0"
|
version = "48.0.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/9f/a9/db8f313fdcd85d767d4973515e1db101f9c71f95fced83233de224673757/cryptography-48.0.0.tar.gz", hash = "sha256:5c3932f4436d1cccb036cb0eaef46e6e2db91035166f1ad6505c3c9d5a635920", size = 832984, upload-time = "2026-05-04T22:59:38.133Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" },
|
{ url = "https://files.pythonhosted.org/packages/b2/6e/e90527eef33f309beb811cf7c982c3aeffcce8e3edb178baa4ca3ae4a6fa/cryptography-48.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5333311663ea94f75dd408665686aaf426563556bb5283554a3539177e03b8c", size = 4690433, upload-time = "2026-05-04T22:57:40.373Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" },
|
{ url = "https://files.pythonhosted.org/packages/90/04/673510ed51ddff56575f306cf1617d80411ee76831ccd3097599140efdfe/cryptography-48.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7995ef305d7165c3f11ae07f2517e5a4f1d5c18da1376a0a9ed496336b69e5f3", size = 4710620, upload-time = "2026-05-04T22:57:42.935Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" },
|
{ url = "https://files.pythonhosted.org/packages/14/d5/e9c4ef932c8d800490c34d8bd589d64a31d5890e27ec9e9ad532be893294/cryptography-48.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:40ba1f85eaa6959837b1d51c9767e230e14612eea4ef110ee8854ada22da1bf5", size = 4696283, upload-time = "2026-05-04T22:57:45.294Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/29/174b9dfb60b12d59ecfc6cfa04bc88c21b42a54f01b8aae09bb6e51e4c7f/cryptography-48.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:369a6348999f94bbd53435c894377b20ab95f25a9065c283570e70150d8abc3c", size = 5296573, upload-time = "2026-05-04T22:57:47.933Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" },
|
{ url = "https://files.pythonhosted.org/packages/95/38/0d29a6fd7d0d1373f0c0c88a04ba20e359b257753ac497564cd660fc1d55/cryptography-48.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:a0e692c683f4df67815a2d258b324e66f4738bd7a96a218c826dce4f4bd05d8f", size = 4743677, upload-time = "2026-05-04T22:57:50.067Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" },
|
{ url = "https://files.pythonhosted.org/packages/30/be/eef653013d5c63b6a490529e0316f9ac14a37602965d4903efed1399f32b/cryptography-48.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:18349bbc56f4743c8b12dc32e2bccb2cf83ee8b69a3bba74ef8ae857e26b3d25", size = 4330808, upload-time = "2026-05-04T22:57:52.301Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" },
|
{ url = "https://files.pythonhosted.org/packages/84/9e/500463e87abb7a0a0f9f256ec21123ecde0a7b5541a15e840ea54551fd81/cryptography-48.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e8eac43dfca5c4cccc6dad9a80504436fca53bb9bc3100a2386d730fbe6b602", size = 4695941, upload-time = "2026-05-04T22:57:54.603Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" },
|
{ url = "https://files.pythonhosted.org/packages/e3/dc/7303087450c2ec9e7fbb750e17c2abfbc658f23cbd0e54009509b7cc4091/cryptography-48.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9ccdac7d40688ecb5a3b4a604b8a88c8002e3442d6c60aead1db2a89a041560c", size = 5252579, upload-time = "2026-05-04T22:57:57.207Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" },
|
{ url = "https://files.pythonhosted.org/packages/d0/c0/7101d3b7215edcdc90c45da544961fd8ed2d6448f77577460fa75a8443f7/cryptography-48.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:bd72e68b06bb1e96913f97dd4901119bc17f39d4586a5adf2d3e47bc2b9d58b5", size = 4743326, upload-time = "2026-05-04T22:57:59.535Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" },
|
{ url = "https://files.pythonhosted.org/packages/ac/d8/5b833bad13016f562ab9d063d68199a4bd121d18458e439515601d3357ec/cryptography-48.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:59baa2cb386c4f0b9905bd6eb4c2a79a69a128408fd31d32ca4d7102d4156321", size = 4826672, upload-time = "2026-05-04T22:58:01.996Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" },
|
{ url = "https://files.pythonhosted.org/packages/98/e1/7074eb8bf3c135558c73fc2bcf0f5633f912e6fb87e868a55c454080ef09/cryptography-48.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9249e3cd978541d665967ac2cb2787fd6a62bddf1e75b3e347a594d7dacf4f74", size = 4972574, upload-time = "2026-05-04T22:58:03.968Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" },
|
{ url = "https://files.pythonhosted.org/packages/89/6e/18e07a618bb5442ba10cf4df16e99c071365528aa570dfcb8c02e25a303b/cryptography-48.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8c7378637d7d88016fa6791c159f698b3d3eed28ebf844ac36b9dc04a14dae18", size = 4684776, upload-time = "2026-05-04T22:58:13.712Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" },
|
{ url = "https://files.pythonhosted.org/packages/be/6a/4ea3b4c6c6759794d5ee2103c304a5076dc4b19ae1f9fe47dba439e159e9/cryptography-48.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cc90c0b39b2e3c65ef52c804b72e3c58f8a04ab2a1871272798e5f9572c17d20", size = 4698121, upload-time = "2026-05-04T22:58:16.448Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" },
|
{ url = "https://files.pythonhosted.org/packages/2f/59/6ff6ad6cae03bb887da2a5860b2c9805f8dac969ef01ce563336c49bd1d1/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:76341972e1eff8b4bea859f09c0d3e64b96ce931b084f9b9b7db8ef364c30eff", size = 4690042, upload-time = "2026-05-04T22:58:18.544Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" },
|
{ url = "https://files.pythonhosted.org/packages/ca/b4/fc334ed8cfd705aca282fe4d8f5ae64a8e0f74932e9feecb344610cf6e4d/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:55b7718303bf06a5753dcdccf2f3945cf18ad7bffde41b61226e4db31ab89a9c", size = 5282526, upload-time = "2026-05-04T22:58:20.75Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" },
|
{ url = "https://files.pythonhosted.org/packages/11/08/9f8c5386cc4cd90d8255c7cdd0f5baf459a08502a09de30dc51f553d38dc/cryptography-48.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:a64697c641c7b1b2178e573cbc31c7c6684cd56883a478d75143dbb7118036db", size = 4733116, upload-time = "2026-05-04T22:58:23.627Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" },
|
{ url = "https://files.pythonhosted.org/packages/b8/77/99307d7574045699f8805aa500fa0fb83422d115b5400a064ddd306d7750/cryptography-48.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:561215ea3879cb1cbbf272867e2efda62476f240fb58c64de6b393ae19246741", size = 4316030, upload-time = "2026-05-04T22:58:25.581Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" },
|
{ url = "https://files.pythonhosted.org/packages/fd/36/a608b98337af3cb2aff4818e406649d30572b7031918b04c87d979495348/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ad64688338ed4bc1a6618076ba75fd7194a5f1797ac60b47afe926285adb3166", size = 4689640, upload-time = "2026-05-04T22:58:27.747Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" },
|
{ url = "https://files.pythonhosted.org/packages/dd/a6/825010a291b4438aecc1f568bc428189fc1175515223632477c07dc0a6df/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:906cbf0670286c6e0044156bc7d4af9cbb0ef6db9f73e52c3ec56ba6bdde5336", size = 5237657, upload-time = "2026-05-04T22:58:29.848Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" },
|
{ url = "https://files.pythonhosted.org/packages/b9/09/4e76a09b4caa29aad535ddc806f5d4c5d01885bd978bd984fbc6ca032cae/cryptography-48.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:ea8990436d914540a40ab24b6a77c0969695ed52f4a4874c5137ccf7045a7057", size = 4732362, upload-time = "2026-05-04T22:58:32.009Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" },
|
{ url = "https://files.pythonhosted.org/packages/18/78/444fa04a77d0cb95f417dda20d450e13c56ba8e5220fc892a1658f44f882/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c18684a7f0cc9a3cb60328f496b8e3372def7c5d2df39ac267878b05565aaaae", size = 4819580, upload-time = "2026-05-04T22:58:34.254Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" },
|
{ url = "https://files.pythonhosted.org/packages/38/85/ea67067c70a1fd4be2c63d35eeed82658023021affccc7b17705f8527dd2/cryptography-48.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9be5aafa5736574f8f15f262adc81b2a9869e2cfe9014d52a44633905b40d52c", size = 4963283, upload-time = "2026-05-04T22:58:36.376Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" },
|
{ url = "https://files.pythonhosted.org/packages/d5/ac/f5b5995b87770c693e2596559ffafe195b4033a57f14a82268a2842953f3/cryptography-48.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:614d0949f4790582d2cc25553abd09dd723025f0c0e7c67376a1d77196743d6e", size = 4683266, upload-time = "2026-05-04T22:58:46.064Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/c6/8b14f67e18338fbc4adb76f66c001f5c3610b3e2d1837f268f47a347dbbb/cryptography-48.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7ce4bfae76319a532a2dc68f82cc32f5676ee792a983187dac07183690e5c66f", size = 4696228, upload-time = "2026-05-04T22:58:48.22Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" },
|
{ url = "https://files.pythonhosted.org/packages/ea/73/f808fbae9514bd91b47875b003f13e284c8c6bdfd904b7944e803937eec1/cryptography-48.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:2eb992bbd4661238c5a397594c83f5b4dc2bc5b848c365c8f991b6780efcc5c7", size = 4689097, upload-time = "2026-05-04T22:58:50.9Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" },
|
{ url = "https://files.pythonhosted.org/packages/93/01/d86632d7d28db8ae83221995752eeb6639ffb374c2d22955648cf8d52797/cryptography-48.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:22a5cb272895dce158b2cacdfdc3debd299019659f42947dbdac6f32d68fe832", size = 5283582, upload-time = "2026-05-04T22:58:53.017Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" },
|
{ url = "https://files.pythonhosted.org/packages/02/e1/50edc7a50334807cc4791fc4a0ce7468b4a1416d9138eab358bfc9a3d70b/cryptography-48.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:2b4d59804e8408e2fea7d1fbaf218e5ec984325221db76e6a241a9abd6cdd95c", size = 4730479, upload-time = "2026-05-04T22:58:55.611Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" },
|
{ url = "https://files.pythonhosted.org/packages/6f/af/99a582b1b1641ff5911ac559beb45097cf79efd4ead4657f578ef1af2d47/cryptography-48.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:984a20b0f62a26f48a3396c72e4bc34c66e356d356bf370053066b3b6d54634a", size = 4326481, upload-time = "2026-05-04T22:58:57.607Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" },
|
{ url = "https://files.pythonhosted.org/packages/90/ee/89aa26a06ef0a7d7611788ffd571a7c50e368cc6a4d5eef8b4884e866edb/cryptography-48.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5a5ed8fde7a1d09376ca0b40e68cd59c69fe23b1f9768bd5824f54681626032a", size = 4688713, upload-time = "2026-05-04T22:59:00.077Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" },
|
{ url = "https://files.pythonhosted.org/packages/70/ba/bcb1b0bb7a33d4c7c0c4d4c7874b4a62ae4f56113a5f4baefa362dfb1f0f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:8cd666227ef7af430aa5914a9910e0ddd703e75f039cef0825cd0da71b6b711a", size = 5238165, upload-time = "2026-05-04T22:59:02.317Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" },
|
{ url = "https://files.pythonhosted.org/packages/c9/70/ca4003b1ce5ca3dc3186ada51908c8a9b9ff7d5cab83cc0d43ee14ec144f/cryptography-48.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9071196d81abc88b3516ac8cdfad32e2b66dd4a5393a8e68a961e9161ddc6239", size = 4729947, upload-time = "2026-05-04T22:59:05.255Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" },
|
{ url = "https://files.pythonhosted.org/packages/44/a0/4ec7cf774207905aef1a8d11c3750d5a1db805eb380ee4e16df317870128/cryptography-48.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1e2d54c8be6152856a36f0882ab231e70f8ec7f14e93cf87db8a2ed056bf160c", size = 4822059, upload-time = "2026-05-04T22:59:07.802Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" },
|
{ url = "https://files.pythonhosted.org/packages/1e/75/a2e55f99c16fcac7b5d6c1eb19ad8e00799854d6be5ca845f9259eae1681/cryptography-48.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a5da777e32ffed6f85a7b2b3f7c5cbc88c146bfcd0a1d7baf5fcc6c52ee35dd4", size = 4960575, upload-time = "2026-05-04T22:59:09.851Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -599,11 +637,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "idna"
|
name = "idna"
|
||||||
version = "3.13"
|
version = "3.15"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/cc/762dfb036166873f0059f3b7de4565e1b5bc3d6f28a414c13da27e442f99/idna-3.13.tar.gz", hash = "sha256:585ea8fe5d69b9181ec1afba340451fba6ba764af97026f92a91d4eef164a242", size = 194210, upload-time = "2026-04-22T16:42:42.314Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/5d/13/ad7d7ca3808a898b4612b6fe93cde56b53f3034dcde235acb1f0e1df24c6/idna-3.13-py3-none-any.whl", hash = "sha256:892ea0cde124a99ce773decba204c5552b69c3c67ffd5f232eb7696135bc8bb3", size = 68629, upload-time = "2026-04-22T16:42:40.909Z" },
|
{ url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -638,14 +676,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "jaraco-functools"
|
name = "jaraco-functools"
|
||||||
version = "4.4.0"
|
version = "4.5.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "more-itertools" },
|
{ name = "more-itertools" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/36/cf/ea4ef2920830dea3f5ab2ea4da6fb67724e6dca80ee2553788c3607243d0/jaraco_functools-4.5.0.tar.gz", hash = "sha256:3bb5665ea4a020cf78a7040e89154c77edadb3ca74f366479669c5999aa70b03", size = 20272, upload-time = "2026-05-15T21:34:10.025Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" },
|
{ url = "https://files.pythonhosted.org/packages/96/9a/982e48afcffcd727a9144506720ffd4224b6b7e355c98641866f38b7c043/jaraco_functools-4.5.0-py3-none-any.whl", hash = "sha256:79ce39246eddbde4b3a03b77ea5f0f7878dc669b166a66cf3fa8e266aa3fa2f4", size = 10594, upload-time = "2026-05-15T21:34:08.595Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -688,36 +726,36 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "librt"
|
name = "librt"
|
||||||
version = "0.9.0"
|
version = "0.11.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/6b/3d5c13fb3e3c4f43206c8f9dfed13778c2ed4f000bacaa0b7ce3c402a265/librt-0.9.0.tar.gz", hash = "sha256:a0951822531e7aee6e0dfb556b30d5ee36bbe234faf60c20a16c01be3530869d", size = 184368, upload-time = "2026-04-09T16:06:26.173Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/cd/c1/184e539543f06ea2912f4b92a5ffaede4f9b392689e3f00acbf8134bee92/librt-0.9.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:3f05d145df35dca5056a8bc3838e940efebd893a54b3e19b2dda39ceaa299bcb", size = 67830, upload-time = "2026-04-09T16:05:34.517Z" },
|
{ url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f3/ad/23399bdcb7afca819acacdef31b37ee59de261bd66b503a7995c03c4b0dc/librt-0.9.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1c587494461ebd42229d0f1739f3aa34237dd9980623ecf1be8d3bcba79f4499", size = 70280, upload-time = "2026-04-09T16:05:35.649Z" },
|
{ url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9f/0b/4542dc5a2b8772dbf92cafb9194701230157e73c14b017b6961a23598b03/librt-0.9.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b0a2040f801406b93657a70b72fa12311063a319fee72ce98e1524da7200171f", size = 201925, upload-time = "2026-04-09T16:05:36.739Z" },
|
{ url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/31/d4/8ee7358b08fd0cfce051ef96695380f09b3c2c11b77c9bfbc367c921cce5/librt-0.9.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f38bc489037eca88d6ebefc9c4d41a4e07c8e8b4de5188a9e6d290273ad7ebb1", size = 212381, upload-time = "2026-04-09T16:05:38.043Z" },
|
{ url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f2/94/a2025fe442abedf8b038038dab3dba942009ad42b38ea064a1a9e6094241/librt-0.9.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3fd278f5e6bf7c75ccd6d12344eb686cc020712683363b66f46ac79d37c799f", size = 227065, upload-time = "2026-04-09T16:05:39.394Z" },
|
{ url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7c/e9/b9fcf6afa909f957cfbbf918802f9dada1bd5d3c1da43d722fd6a310dc3f/librt-0.9.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fcbdf2a9ca24e87bbebb47f1fe34e531ef06f104f98c9ccfc953a3f3344c567a", size = 221333, upload-time = "2026-04-09T16:05:40.999Z" },
|
{ url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ac/7c/ba54cd6aa6a3c8cd12757a6870e0c79a64b1e6327f5248dcff98423f4d43/librt-0.9.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e306d956cfa027fe041585f02a1602c32bfa6bb8ebea4899d373383295a6c62f", size = 229051, upload-time = "2026-04-09T16:05:42.605Z" },
|
{ url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/4b/8cfdbad314c8677a0148bf0b70591d6d18587f9884d930276098a235461b/librt-0.9.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:465814ab157986acb9dfa5ccd7df944be5eefc0d08d31ec6e8d88bc71251d845", size = 222492, upload-time = "2026-04-09T16:05:43.842Z" },
|
{ url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1f/d1/2eda69563a1a88706808decdce035e4b32755dbfbb0d05e1a65db9547ed1/librt-0.9.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:703f4ae36d6240bfe24f542bac784c7e4194ec49c3ba5a994d02891649e2d85b", size = 223849, upload-time = "2026-04-09T16:05:45.054Z" },
|
{ url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/04/44/b2ed37df6be5b3d42cfe36318e0598e80843d5c6308dd63d0bf4e0ce5028/librt-0.9.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3be322a15ee5e70b93b7a59cfd074614f22cc8c9ff18bd27f474e79137ea8d3b", size = 245001, upload-time = "2026-04-09T16:05:46.34Z" },
|
{ url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/e7/617e412426df89169dd2a9ed0cc8752d5763336252c65dbf945199915119/librt-0.9.0-cp314-cp314-win32.whl", hash = "sha256:b8da9f8035bb417770b1e1610526d87ad4fc58a2804dc4d79c53f6d2cf5a6eb9", size = 51799, upload-time = "2026-04-09T16:05:47.738Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/ed/c22ca4db0ca3cbc285e4d9206108746beda561a9792289c3c31281d7e9df/librt-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:b8bd70d5d816566a580d193326912f4a76ec2d28a97dc4cd4cc831c0af8e330e", size = 59165, upload-time = "2026-04-09T16:05:49.198Z" },
|
{ url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/24/56/875398fafa4cbc8f15b89366fc3287304ddd3314d861f182a4b87595ace0/librt-0.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:fc5758e2b7a56532dc33e3c544d78cbaa9ecf0a0f2a2da2df882c1d6b99a317f", size = 49292, upload-time = "2026-04-09T16:05:50.362Z" },
|
{ url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4c/61/bc448ecbf9b2d69c5cff88fe41496b19ab2a1cbda0065e47d4d0d51c0867/librt-0.9.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f24b90b0e0c8cc9491fb1693ae91fe17cb7963153a1946395acdbdd5818429a4", size = 70175, upload-time = "2026-04-09T16:05:51.564Z" },
|
{ url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/60/f2/c47bb71069a73e2f04e70acbd196c1e5cc411578ac99039a224b98920fd4/librt-0.9.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3fe56e80badb66fdcde06bef81bbaa5bfcf6fbd7aefb86222d9e369c38c6b228", size = 72951, upload-time = "2026-04-09T16:05:52.699Z" },
|
{ url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/19/0549df59060631732df758e8886d92088da5fdbedb35b80e4643664e8412/librt-0.9.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:527b5b820b47a09e09829051452bb0d1dd2122261254e2a6f674d12f1d793d54", size = 225864, upload-time = "2026-04-09T16:05:53.895Z" },
|
{ url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/f8/3b144396d302ac08e50f89e64452c38db84bc7b23f6c60479c5d3abd303c/librt-0.9.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d429bdd4ac0ab17c8e4a8af0ed2a7440b16eba474909ab357131018fe8c7e71", size = 241155, upload-time = "2026-04-09T16:05:55.191Z" },
|
{ url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7a/ce/ee67ec14581de4043e61d05786d2aed6c9b5338816b7859bcf07455c6a9f/librt-0.9.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7202bdcac47d3a708271c4304a474a8605a4a9a4a709e954bf2d3241140aa938", size = 252235, upload-time = "2026-04-09T16:05:56.549Z" },
|
{ url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/fa/0ead15daa2b293a54101550b08d4bafe387b7d4a9fc6d2b985602bae69b6/librt-0.9.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0d620e74897f8c2613b3c4e2e9c1e422eb46d2ddd07df540784d44117836af3", size = 244963, upload-time = "2026-04-09T16:05:57.858Z" },
|
{ url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/29/68/9fbf9a9aa704ba87689e40017e720aced8d9a4d2b46b82451d8142f91ec9/librt-0.9.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d69fc39e627908f4c03297d5a88d9284b73f4d90b424461e32e8c2485e21c283", size = 257364, upload-time = "2026-04-09T16:05:59.686Z" },
|
{ url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1a/8d/9d60869f1b6716c762e45f66ed945b1e5dd649f7377684c3b176ae424648/librt-0.9.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:c2640e23d2b7c98796f123ffd95cf2022c7777aa8a4a3b98b36c570d37e85eee", size = 247661, upload-time = "2026-04-09T16:06:00.938Z" },
|
{ url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/ff/a5c365093962310bfdb4f6af256f191085078ffb529b3f0cbebb5b33ebe2/librt-0.9.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:451daa98463b7695b0a30aa56bf637831ea559e7b8101ac2ef6382e8eb15e29c", size = 248238, upload-time = "2026-04-09T16:06:02.537Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a0/3c/2d34365177f412c9e19c0a29f969d70f5343f27634b76b765a54d8b27705/librt-0.9.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:928bd06eca2c2bbf4349e5b817f837509b0604342e65a502de1d50a7570afd15", size = 269457, upload-time = "2026-04-09T16:06:03.833Z" },
|
{ url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/bc/cd/de45b239ea3bdf626f982a00c14bfcf2e12d261c510ba7db62c5969a27cd/librt-0.9.0-cp314-cp314t-win32.whl", hash = "sha256:a9c63e04d003bc0fb6a03b348018b9a3002f98268200e22cc80f146beac5dc40", size = 52453, upload-time = "2026-04-09T16:06:05.229Z" },
|
{ url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/7f/f9/bfb32ae428aa75c0c533915622176f0a17d6da7b72b5a3c6363685914f70/librt-0.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f162af66a2ed3f7d1d161a82ca584efd15acd9c1cff190a373458c32f7d42118", size = 60044, upload-time = "2026-04-09T16:06:06.398Z" },
|
{ url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/47/7d70414bcdbb3bc1f458a8d10558f00bbfdb24e5a11740fc8197e12c3255/librt-0.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:a4b25c6c25cac5d0d9d6d6da855195b254e0021e513e0249f0e3b444dc6e0e61", size = 50009, upload-time = "2026-04-09T16:06:07.995Z" },
|
{ url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -734,14 +772,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markdown-it-py"
|
name = "markdown-it-py"
|
||||||
version = "4.0.0"
|
version = "4.2.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "mdurl" },
|
{ name = "mdurl" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/f5/4ec618ed16cc4f8fb3b701563655a69816155e79e24a17b651541804721d/markdown_it_py-4.0.0.tar.gz", hash = "sha256:cb0a2b4aa34f932c007117b194e945bd74e0ec24133ceb5bac59009cda1cb9f3", size = 73070, upload-time = "2025-08-11T12:57:52.854Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/54/e7d793b573f298e1c9013b8c4dade17d481164aa517d1d7148619c2cedbf/markdown_it_py-4.0.0-py3-none-any.whl", hash = "sha256:87327c59b172c5011896038353a81343b6754500a08cd7a4973bb48c6d578147", size = 87321, upload-time = "2025-08-11T12:57:51.923Z" },
|
{ url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -913,31 +951,32 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mypy"
|
name = "mypy"
|
||||||
version = "1.20.2"
|
version = "2.1.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "ast-serialize" },
|
||||||
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
{ name = "librt", marker = "platform_python_implementation != 'PyPy'" },
|
||||||
{ name = "mypy-extensions" },
|
{ name = "mypy-extensions" },
|
||||||
{ name = "pathspec" },
|
{ name = "pathspec" },
|
||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/04/af/e3d4b3e9ec91a0ff9aabfdb38692952acf49bbb899c2e4c29acb3a6da3ae/mypy-1.20.2.tar.gz", hash = "sha256:e8222c26daaafd9e8626dec58ae36029f82585890589576f769a650dd20fd665", size = 3817349, upload-time = "2026-04-21T17:12:28.473Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/82/15/cca9d88503549ed6fedeaa1d448cdddd542ee8a490232d732e278036fbf2/mypy-2.1.0.tar.gz", hash = "sha256:81e76ad12c2d804512e9b13240d1588316531bfba07558286078bfbce9613633", size = 3898359, upload-time = "2026-05-11T18:37:36.237Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/d1/b4ec96b0ecc620a4443570c6e95c867903428cfcde4206518eafdd5880c3/mypy-1.20.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:419413398fe250aae057fd2fe50166b61077083c9b82754c341cf4fd73038f30", size = 14524561, upload-time = "2026-04-21T17:06:27.325Z" },
|
{ url = "https://files.pythonhosted.org/packages/b0/ca/b279a672e874aedd5498ae25f722dacc8aa86bbffb939b3f97cbb1cf6686/mypy-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7354c5a7f69d9345c3d6e69921d57088eea3ddeeb6b20d34c1b3855b02c36ec2", size = 14848422, upload-time = "2026-05-11T18:35:45.984Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/3a/63/d2c2ff4fa66bc49477d32dfa26e8a167ba803ea6a69c5efb416036909d30/mypy-1.20.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e73c07f23009962885c197ccb9b41356a30cc0e5a1d0c2ea8fd8fb1362d7f924", size = 13363883, upload-time = "2026-04-21T17:11:11.239Z" },
|
{ url = "https://files.pythonhosted.org/packages/27/e6/3efe56c631d959b9b4454e208b0ac4b7f4f58b404c89f8bec7b49efdfc21/mypy-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:49890d4f76ac9e06ec117f9e09f3174da70a620a0c300953d8595c926e80947f", size = 13677374, upload-time = "2026-05-11T18:36:57.188Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/56/983916806bf4eddeaaa2c9230903c3669c6718552a921154e1c5182c701f/mypy-1.20.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c64e5973df366b747646fc98da921f9d6eba9716d57d1db94a83c026a08e0fb", size = 13742945, upload-time = "2026-04-21T17:08:34.181Z" },
|
{ url = "https://files.pythonhosted.org/packages/84/7f/8107ea87a44fd1f1b59882442f033c9c3488c127201b1d1d15f1cbd6022e/mypy-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:761be68e023ef5d94678772396a8af1220030f80837a3afd8d0aef3b419666f4", size = 14055743, upload-time = "2026-05-11T18:35:18.361Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/19/65/0cd9285ab010ee8214c83d67c6b49417c40d86ce46f1aa109457b5a9b8d7/mypy-1.20.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a65aa591af023864fd08a97da9974e919452cfe19cb146c8a5dc692626445dc", size = 14706163, upload-time = "2026-04-21T17:05:15.51Z" },
|
{ url = "https://files.pythonhosted.org/packages/51/4d/b6d34db183133b83761b9199a82d31557cdbb70a380d8c3b3438e11882a3/mypy-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c90345fc182dc363b891350457ec69c35140858538f38b4540845afcc32b1aef", size = 15020937, upload-time = "2026-05-11T18:34:59.618Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/94/97/48ff3b297cafcc94d185243a9190836fb1b01c1b0918fff64e941e973cc9/mypy-1.20.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:4fef51b01e638974a6e69885687e9bd40c8d1e09a6cd291cca0619625cf1f558", size = 14938677, upload-time = "2026-04-21T17:05:39.562Z" },
|
{ url = "https://files.pythonhosted.org/packages/ff/d7/f08360c691d758acb02f45022c34d98b92892f4ea756644e1000d4b9f3d8/mypy-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b84802e7b5a6daf1f5e15bc9fcd7ddae77be13981ffab037f1c67bb84d67d135", size = 15253371, upload-time = "2026-05-11T18:36:41.081Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/fd/a1/1b4233d255bdd0b38a1f284feeb1c143ca508c19184964e22f8d837ec851/mypy-1.20.2-cp314-cp314-win_amd64.whl", hash = "sha256:913485a03f1bcf5d279409a9d2b9ed565c151f61c09f29991e5faa14033da4c8", size = 11089322, upload-time = "2026-04-21T17:06:44.29Z" },
|
{ url = "https://files.pythonhosted.org/packages/67/1b/09460a13719530a19bce27bd3bc8449e83569dd2ba7faf51c9c3c30c0b61/mypy-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:022c771234936ceac541ebaf836fe9e2abeb3f5e09aff21588fe543ff006fe21", size = 11326429, upload-time = "2026-05-11T18:34:13.526Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/c2/ce7ee2ba36aeb954ba50f18fa25d9c1188578654b97d02a66a15b6f09531/mypy-1.20.2-cp314-cp314-win_arm64.whl", hash = "sha256:c3bae4f855d965b5453784300c12ffc63a548304ac7f99e55d4dc7c898673aa3", size = 10017775, upload-time = "2026-04-21T17:07:20.732Z" },
|
{ url = "https://files.pythonhosted.org/packages/40/62/75dbf0f82f7b6680340efc614af29dd0b3c17b8a4f1cd09b8bd2fd6bc814/mypy-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:498207db725cec88829a6a5c2fc771205fd043719ef98bc49aba8fb9fc4e6d57", size = 10218799, upload-time = "2026-05-11T18:32:23.491Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4e/a1/9d93a7d0b5859af0ead82b4888b46df6c8797e1bc5e1e262a08518c6d48e/mypy-1.20.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2de3dcea53babc1c3237a19002bc3d228ce1833278f093b8d619e06e7cc79609", size = 15549002, upload-time = "2026-04-21T17:08:23.107Z" },
|
{ url = "https://files.pythonhosted.org/packages/b2/66/caca04ed7d972fb6eb6dd1ccd6df1de5c38fae8c5b3dc1c4e8e0d85ee6b9/mypy-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7d5e5cad0efeba72b93cd17490cc0d69c5ac9ca132994fe3fb0314808aeeb83e", size = 15923458, upload-time = "2026-05-11T18:35:28.64Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/00/d2/09a6a10ee1bf0008f6c144d9676f2ca6a12512151b4e0ad0ff6c4fac5337/mypy-1.20.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:52b176444e2e5054dfcbcb8c75b0b719865c96247b37407184bbfca5c353f2c2", size = 14401942, upload-time = "2026-04-21T17:07:31.837Z" },
|
{ url = "https://files.pythonhosted.org/packages/ed/52/2d90cbe49d014b13ed7ff337930c30bad35893fe38a1e4641e756bb62191/mypy-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ff715050c127d724fd260a2e666e7747fdd83511c0c47d449d98238970aef780", size = 14757697, upload-time = "2026-05-11T18:36:14.208Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/57/da/9594b75c3c019e805250bed3583bdf4443ff9e6ef08f97e39ae308cb06f2/mypy-1.20.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:688c3312e5dadb573a2c69c82af3a298d43ecf9e6d264e0f95df960b5f6ac19c", size = 15041649, upload-time = "2026-04-21T17:09:34.653Z" },
|
{ url = "https://files.pythonhosted.org/packages/ac/37/d98f4a14e081b238992d0ed96b6d39c7cc0148c9699eb71eaa68629665ea/mypy-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:82208da9e09414d520e912d3e462d454854bed0810b71540bb016dcbca7308fd", size = 15405638, upload-time = "2026-05-11T18:33:48.249Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/97/77/f75a65c278e6e8eba2071f7f5a90481891053ecc39878cc444634d892abe/mypy-1.20.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29752dbbf8cc53f89f6ac096d363314333045c257c9c75cbd189ca2de0455744", size = 15864588, upload-time = "2026-04-21T17:11:44.936Z" },
|
{ url = "https://files.pythonhosted.org/packages/a3/c2/15c46613b24a84fad2aea1248bf9619b99c2767ae9071fe224c179a0b7d4/mypy-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e79ebc1b904b84f0310dff7469655a9c36c7a68bddb37bdd42b67a332df61d08", size = 16215852, upload-time = "2026-05-11T18:32:50.296Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/46/1a4e1c66e96c1a3246ddf5403d122ac9b0a8d2b7e65730b9d6533ba7a6d3/mypy-1.20.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:803203d2b6ea644982c644895c2f78b28d0e208bba7b27d9b921e0ec5eb207c6", size = 16093956, upload-time = "2026-04-21T17:10:17.683Z" },
|
{ url = "https://files.pythonhosted.org/packages/5c/90/9c16a57f482c76d25f6379762b56bbf65c711d8158cf271fb2802cfb0640/mypy-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e583edc957cfb0deb142079162ae826f58449b116c1d442f2d91c69d9fced081", size = 16452695, upload-time = "2026-05-11T18:33:38.182Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5a/2c/78a8851264dec38cd736ca5b8bc9380674df0dd0be7792f538916157716c/mypy-1.20.2-cp314-cp314t-win_amd64.whl", hash = "sha256:9bcb8aa397ff0093c824182fd76a935a9ba7ad097fcbef80ae89bf6c1731d8ec", size = 12568661, upload-time = "2026-04-21T17:11:54.473Z" },
|
{ url = "https://files.pythonhosted.org/packages/0f/4c/215a4eeb63cacc5f17f516691ea7285d11e249802b942476bff15922a314/mypy-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b33b6cd332695bba180d55e717a79d3038e479a2c49cc5eb3d53603409b9a5d7", size = 12866622, upload-time = "2026-05-11T18:34:39.945Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/83/01/cd7318aa03493322ce275a0e14f4f52b8896335e4e79d4fb8153a7ad2b77/mypy-1.20.2-cp314-cp314t-win_arm64.whl", hash = "sha256:e061b58443f1736f8a37c48978d7ab581636d6ab03e3d4f99e3fa90463bb9382", size = 10389240, upload-time = "2026-04-21T17:09:42.719Z" },
|
{ url = "https://files.pythonhosted.org/packages/4b/50/1043e1db5f455ffe4c9ab22747cd8ca2bc492b1e4f4e21b130a44ee2b217/mypy-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:4f910fe825376a7b66ef7ca8c98e5a149e8cd64c19ae71d84047a74ee060d4e6", size = 10610798, upload-time = "2026-05-11T18:36:31.444Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/28/9a/f23c163e25b11074188251b0b5a0342625fc1cdb6af604757174fa9acc9b/mypy-1.20.2-py3-none-any.whl", hash = "sha256:a94c5a76ab46c5e6257c7972b6c8cff0574201ca7dc05647e33e795d78680563", size = 2637314, upload-time = "2026-04-21T17:05:54.5Z" },
|
{ url = "https://files.pythonhosted.org/packages/0d/2a/13ca1f292f6db1b98ff495ef3467736b331621c5917cad984b7043e7348d/mypy-2.1.0-py3-none-any.whl", hash = "sha256:a663814603a5c563fb87a4f96fb473eeb30d1f5a4885afcf44f9db000a366289", size = 2693302, upload-time = "2026-05-11T18:31:29.246Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -980,12 +1019,12 @@ dev = [
|
|||||||
{ name = "codespell", specifier = ">=2.4.2" },
|
{ name = "codespell", specifier = ">=2.4.2" },
|
||||||
{ name = "hatch", specifier = ">=1.16.5" },
|
{ name = "hatch", specifier = ">=1.16.5" },
|
||||||
{ name = "maubot", extras = ["encryption"], specifier = ">=0.6.0" },
|
{ name = "maubot", extras = ["encryption"], specifier = ">=0.6.0" },
|
||||||
{ name = "mypy", specifier = ">=1.20.2" },
|
{ name = "mypy", specifier = ">=2.1.0" },
|
||||||
{ name = "pip-audit", specifier = ">=2.10.0" },
|
{ name = "pip-audit", specifier = ">=2.10.0" },
|
||||||
{ name = "pytest", specifier = ">=9.0.3" },
|
{ name = "pytest", specifier = ">=9.0.3" },
|
||||||
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
{ name = "pytest-asyncio", specifier = ">=1.3.0" },
|
||||||
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
{ name = "pytest-cov", specifier = ">=7.1.0" },
|
||||||
{ name = "ruff", specifier = ">=0.15.12" },
|
{ name = "ruff", specifier = ">=0.15.13" },
|
||||||
{ name = "time-machine", specifier = ">=3.2.0" },
|
{ name = "time-machine", specifier = ">=3.2.0" },
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -1030,11 +1069,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pip"
|
name = "pip"
|
||||||
version = "26.1"
|
version = "26.1.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/73/7e/d2b04004e1068ad4fdfa2f227b839b5d03e602e47cdbbf49de71137c9546/pip-26.1.tar.gz", hash = "sha256:81e13ebcca3ffa8cc85e4deff5c27e1ee26dea0aa7fc2f294a073ac208806ff3", size = 1840316, upload-time = "2026-04-26T21:00:05.406Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/b6/48/cb9b7a682f6fe01a4221e1728941dd4ac3cd9090a17db3779d6ff490b602/pip-26.1.1.tar.gz", hash = "sha256:d36762751d156a4ee895de8af39aa0abeeeb577f93a2eca6ab62467bbf0f8a78", size = 1840400, upload-time = "2026-05-04T19:02:21.248Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/70/7a/be4bd8bcbb24ea475856dd68159d78b03b2bb53dae369f69c9606b8888f5/pip-26.1-py3-none-any.whl", hash = "sha256:4e8486d821d814b77319acb7b9e8bf5a4ee7590a643e7cb21029f209be8573c1", size = 1812804, upload-time = "2026-04-26T21:00:03.194Z" },
|
{ url = "https://files.pythonhosted.org/packages/3a/eb/fea4d1d51c49832120f7f285d07306db3960f423a2612c6057caf3e8196f/pip-26.1.1-py3-none-any.whl", hash = "sha256:99cb1c2899893b075ff56e4ed0af55669a955b49ad7fb8d8603ecdaf4ed653fb", size = 1812777, upload-time = "2026-05-04T19:02:18.9Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1124,41 +1163,45 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "propcache"
|
name = "propcache"
|
||||||
version = "0.4.1"
|
version = "0.5.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/da/e9fc233cf63743258bff22b3dfa7ea5baef7b5bc324af47a0ad89b8ffc6f/propcache-0.4.1.tar.gz", hash = "sha256:f48107a8c637e80362555f37ecf49abe20370e557cc4ab374f04ec4423c97c3d", size = 46442, upload-time = "2025-10-08T19:49:02.291Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/8e/5c/bca52d654a896f831b8256683457ceddd490ec18d9ec50e97dfd8fc726a8/propcache-0.4.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3f7124c9d820ba5548d431afb4632301acf965db49e666aa21c305cbe8c6de12", size = 78152, upload-time = "2025-10-08T19:47:51.051Z" },
|
{ url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/65/9b/03b04e7d82a5f54fb16113d839f5ea1ede58a61e90edf515f6577c66fa8f/propcache-0.4.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c0d4b719b7da33599dfe3b22d3db1ef789210a0597bc650b7cee9c77c2be8c5c", size = 44869, upload-time = "2025-10-08T19:47:52.594Z" },
|
{ url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b2/fa/89a8ef0468d5833a23fff277b143d0573897cf75bd56670a6d28126c7d68/propcache-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9f302f4783709a78240ebc311b793f123328716a60911d667e0c036bc5dcbded", size = 46596, upload-time = "2025-10-08T19:47:54.073Z" },
|
{ url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/bd/47816020d337f4a746edc42fe8d53669965138f39ee117414c7d7a340cfe/propcache-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c80ee5802e3fb9ea37938e7eecc307fb984837091d5fd262bb37238b1ae97641", size = 206981, upload-time = "2025-10-08T19:47:55.715Z" },
|
{ url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/f6/c5fa1357cc9748510ee55f37173eb31bfde6d94e98ccd9e6f033f2fc06e1/propcache-0.4.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ed5a841e8bb29a55fb8159ed526b26adc5bdd7e8bd7bf793ce647cb08656cdf4", size = 211490, upload-time = "2025-10-08T19:47:57.499Z" },
|
{ url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/80/1e/e5889652a7c4a3846683401a48f0f2e5083ce0ec1a8a5221d8058fbd1adf/propcache-0.4.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:55c72fd6ea2da4c318e74ffdf93c4fe4e926051133657459131a95c846d16d44", size = 215371, upload-time = "2025-10-08T19:47:59.317Z" },
|
{ url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/b2/f2/889ad4b2408f72fe1a4f6a19491177b30ea7bf1a0fd5f17050ca08cfc882/propcache-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8326e144341460402713f91df60ade3c999d601e7eb5ff8f6f7862d54de0610d", size = 201424, upload-time = "2025-10-08T19:48:00.67Z" },
|
{ url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/27/73/033d63069b57b0812c8bd19f311faebeceb6ba31b8f32b73432d12a0b826/propcache-0.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:060b16ae65bc098da7f6d25bf359f1f31f688384858204fe5d652979e0015e5b", size = 197566, upload-time = "2025-10-08T19:48:02.604Z" },
|
{ url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/dc/89/ce24f3dc182630b4e07aa6d15f0ff4b14ed4b9955fae95a0b54c58d66c05/propcache-0.4.1-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:89eb3fa9524f7bec9de6e83cf3faed9d79bffa560672c118a96a171a6f55831e", size = 193130, upload-time = "2025-10-08T19:48:04.499Z" },
|
{ url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a9/24/ef0d5fd1a811fb5c609278d0209c9f10c35f20581fcc16f818da959fc5b4/propcache-0.4.1-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:dee69d7015dc235f526fe80a9c90d65eb0039103fe565776250881731f06349f", size = 202625, upload-time = "2025-10-08T19:48:06.213Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/02/98ec20ff5546f68d673df2f7a69e8c0d076b5abd05ca882dc7ee3a83653d/propcache-0.4.1-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5558992a00dfd54ccbc64a32726a3357ec93825a418a401f5cc67df0ac5d9e49", size = 204209, upload-time = "2025-10-08T19:48:08.432Z" },
|
{ url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a0/87/492694f76759b15f0467a2a93ab68d32859672b646aa8a04ce4864e7932d/propcache-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:c9b822a577f560fbd9554812526831712c1436d2c046cedee4c3796d3543b144", size = 197797, upload-time = "2025-10-08T19:48:09.968Z" },
|
{ url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ee/36/66367de3575db1d2d3f3d177432bd14ee577a39d3f5d1b3d5df8afe3b6e2/propcache-0.4.1-cp314-cp314-win32.whl", hash = "sha256:ab4c29b49d560fe48b696cdcb127dd36e0bc2472548f3bf56cc5cb3da2b2984f", size = 38140, upload-time = "2025-10-08T19:48:11.232Z" },
|
{ url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0c/2a/a758b47de253636e1b8aef181c0b4f4f204bf0dd964914fb2af90a95b49b/propcache-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:5a103c3eb905fcea0ab98be99c3a9a5ab2de60228aa5aceedc614c0281cf6153", size = 41257, upload-time = "2025-10-08T19:48:12.707Z" },
|
{ url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/34/5e/63bd5896c3fec12edcbd6f12508d4890d23c265df28c74b175e1ef9f4f3b/propcache-0.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:74c1fb26515153e482e00177a1ad654721bf9207da8a494a0c05e797ad27b992", size = 38097, upload-time = "2025-10-08T19:48:13.923Z" },
|
{ url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/85/9ff785d787ccf9bbb3f3106f79884a130951436f58392000231b4c737c80/propcache-0.4.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:824e908bce90fb2743bd6b59db36eb4f45cd350a39637c9f73b1c1ea66f5b75f", size = 81455, upload-time = "2025-10-08T19:48:15.16Z" },
|
{ url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/90/85/2431c10c8e7ddb1445c1f7c4b54d886e8ad20e3c6307e7218f05922cad67/propcache-0.4.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:c2b5e7db5328427c57c8e8831abda175421b709672f6cfc3d630c3b7e2146393", size = 46372, upload-time = "2025-10-08T19:48:16.424Z" },
|
{ url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/01/20/b0972d902472da9bcb683fa595099911f4d2e86e5683bcc45de60dd05dc3/propcache-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6f6ff873ed40292cd4969ef5310179afd5db59fdf055897e282485043fc80ad0", size = 48411, upload-time = "2025-10-08T19:48:17.577Z" },
|
{ url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/e3/7dc89f4f21e8f99bad3d5ddb3a3389afcf9da4ac69e3deb2dcdc96e74169/propcache-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49a2dc67c154db2c1463013594c458881a069fcf98940e61a0569016a583020a", size = 275712, upload-time = "2025-10-08T19:48:18.901Z" },
|
{ url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/20/67/89800c8352489b21a8047c773067644e3897f02ecbbd610f4d46b7f08612/propcache-0.4.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:005f08e6a0529984491e37d8dbc3dd86f84bd78a8ceb5fa9a021f4c48d4984be", size = 273557, upload-time = "2025-10-08T19:48:20.762Z" },
|
{ url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e2/a1/b52b055c766a54ce6d9c16d9aca0cad8059acd9637cdf8aa0222f4a026ef/propcache-0.4.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5c3310452e0d31390da9035c348633b43d7e7feb2e37be252be6da45abd1abcc", size = 280015, upload-time = "2025-10-08T19:48:22.592Z" },
|
{ url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/48/c8/33cee30bd890672c63743049f3c9e4be087e6780906bfc3ec58528be59c1/propcache-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4c3c70630930447f9ef1caac7728c8ad1c56bc5015338b20fed0d08ea2480b3a", size = 262880, upload-time = "2025-10-08T19:48:23.947Z" },
|
{ url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0c/b1/8f08a143b204b418285c88b83d00edbd61afbc2c6415ffafc8905da7038b/propcache-0.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e57061305815dfc910a3634dcf584f08168a8836e6999983569f51a8544cd89", size = 260938, upload-time = "2025-10-08T19:48:25.656Z" },
|
{ url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cf/12/96e4664c82ca2f31e1c8dff86afb867348979eb78d3cb8546a680287a1e9/propcache-0.4.1-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:521a463429ef54143092c11a77e04056dd00636f72e8c45b70aaa3140d639726", size = 247641, upload-time = "2025-10-08T19:48:27.207Z" },
|
{ url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/18/ed/e7a9cfca28133386ba52278136d42209d3125db08d0a6395f0cba0c0285c/propcache-0.4.1-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:120c964da3fdc75e3731aa392527136d4ad35868cc556fd09bb6d09172d9a367", size = 262510, upload-time = "2025-10-08T19:48:28.65Z" },
|
{ url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f5/76/16d8bf65e8845dd62b4e2b57444ab81f07f40caa5652b8969b87ddcf2ef6/propcache-0.4.1-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:d8f353eb14ee3441ee844ade4277d560cdd68288838673273b978e3d6d2c8f36", size = 263161, upload-time = "2025-10-08T19:48:30.133Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e7/70/c99e9edb5d91d5ad8a49fa3c1e8285ba64f1476782fed10ab251ff413ba1/propcache-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ab2943be7c652f09638800905ee1bab2c544e537edb57d527997a24c13dc1455", size = 257393, upload-time = "2025-10-08T19:48:31.567Z" },
|
{ url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/08/02/87b25304249a35c0915d236575bc3574a323f60b47939a2262b77632a3ee/propcache-0.4.1-cp314-cp314t-win32.whl", hash = "sha256:05674a162469f31358c30bcaa8883cb7829fa3110bf9c0991fe27d7896c42d85", size = 42546, upload-time = "2025-10-08T19:48:32.872Z" },
|
{ url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/cb/ef/3c6ecf8b317aa982f309835e8f96987466123c6e596646d4e6a1dfcd080f/propcache-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:990f6b3e2a27d683cb7602ed6c86f15ee6b43b1194736f9baaeb93d0016633b1", size = 46259, upload-time = "2025-10-08T19:48:34.226Z" },
|
{ url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c4/2d/346e946d4951f37eca1e4f55be0f0174c52cd70720f84029b02f296f4a38/propcache-0.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ecef2343af4cc68e05131e45024ba34f6095821988a9d0a02aa7c73fcc448aa9", size = 40428, upload-time = "2025-10-08T19:48:35.441Z" },
|
{ url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" },
|
{ url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1281,15 +1324,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "python-discovery"
|
name = "python-discovery"
|
||||||
version = "1.2.2"
|
version = "1.3.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "filelock" },
|
{ name = "filelock" },
|
||||||
{ name = "platformdirs" },
|
{ name = "platformdirs" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" },
|
{ url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1324,7 +1367,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "requests"
|
name = "requests"
|
||||||
version = "2.33.1"
|
version = "2.34.2"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "certifi" },
|
{ name = "certifi" },
|
||||||
@@ -1332,9 +1375,9 @@ dependencies = [
|
|||||||
{ name = "idna" },
|
{ name = "idna" },
|
||||||
{ name = "urllib3" },
|
{ name = "urllib3" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" },
|
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1382,27 +1425,27 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ruff"
|
name = "ruff"
|
||||||
version = "0.15.12"
|
version = "0.15.13"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" },
|
{ url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" },
|
{ url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" },
|
{ url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" },
|
{ url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" },
|
{ url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" },
|
{ url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" },
|
{ url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" },
|
{ url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" },
|
{ url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" },
|
{ url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" },
|
{ url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" },
|
{ url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" },
|
{ url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" },
|
{ url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" },
|
{ url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" },
|
{ url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1513,20 +1556,20 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tomlkit"
|
name = "tomlkit"
|
||||||
version = "0.14.0"
|
version = "0.15.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c3/af/14b24e41977adb296d6bd1fb59402cf7d60ce364f90c890bd2ec65c43b5a/tomlkit-0.14.0.tar.gz", hash = "sha256:cf00efca415dbd57575befb1f6634c4f42d2d87dbba376128adb42c121b87064", size = 187167, upload-time = "2026-01-13T01:14:53.304Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/b5/11/87d6d29fb5d237229d67973a6c9e06e048f01cf4994dee194ab0ea841814/tomlkit-0.14.0-py3-none-any.whl", hash = "sha256:592064ed85b40fa213469f81ac584f67a4f2992509a7c3ea2d632208623a3680", size = 39310, upload-time = "2026-01-13T01:14:51.965Z" },
|
{ url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "trove-classifiers"
|
name = "trove-classifiers"
|
||||||
version = "2026.4.28.13"
|
version = "2026.5.7.17"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/04/af/88fdebf242bc7bc4957c96c5358a2b2b0f07e5001401906783a521ea9f54/trove_classifiers-2026.4.28.13.tar.gz", hash = "sha256:c85bb8a53c3de7330d1699b844ed9fb809a602a09ac15dc79ad6d1a509be0676", size = 17035, upload-time = "2026-04-28T13:45:41.495Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/35/68/175e7c07c5be13200387d5c0995b0da1e198e360047c08eb17d1002fcd92/trove_classifiers-2026.5.7.17.tar.gz", hash = "sha256:a04a48f8f0a787cb996514d3969ac7608aa3c60cb15d073c1e02801e60533e80", size = 17041, upload-time = "2026-05-07T17:48:01.931Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d6/bb/fbdc4e57731efb86b4209ffdd2519520782bf27b3c961beac3e5c20d2b87/trove_classifiers-2026.4.28.13-py3-none-any.whl", hash = "sha256:8f4b1eb4e16296b57d612965444f87a83861cc989a0451ac97fe4265ddef03b8", size = 14216, upload-time = "2026-04-28T13:45:39.943Z" },
|
{ url = "https://files.pythonhosted.org/packages/7b/e3/d81b065a2d866a33a541ac63a2a4cc5737e03ce2379ac3191c98bb8867e3/trove_classifiers-2026.5.7.17-py3-none-any.whl", hash = "sha256:5ec0800de5e2ddbd7c663cb4c0c15328f132dc168813897c18866c5c7b93db33", size = 14201, upload-time = "2026-05-07T17:48:00.488Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1549,11 +1592,11 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "urllib3"
|
name = "urllib3"
|
||||||
version = "2.6.3"
|
version = "2.7.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/24/5f1b3bdffd70275f6661c76461e25f024d5a38a46f04aaca912426a2b1d3/urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed", size = 435556, upload-time = "2026-01-07T16:24:43.925Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/39/08/aaaad47bc4e9dc8c725e68f9d04865dbcb2052843ff09c97b08904852d84/urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4", size = 131584, upload-time = "2026-01-07T16:24:42.685Z" },
|
{ url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1570,33 +1613,33 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uv"
|
name = "uv"
|
||||||
version = "0.11.8"
|
version = "0.11.14"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/c1/cd/4393fecb083897e956f016d4e66d0b8a496a08fe2e03cbda32a1e91da7ee/uv-0.11.8.tar.gz", hash = "sha256:bb2cf302b8503629aab6f0090a05551e6f8cfc2d687ca059cad7ec9e11214335", size = 4098020, upload-time = "2026-04-27T13:15:31.625Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/30/a3/be4a946c7c2fc4094c020c8f7d8bd0a739bad55ebe4e2817d6e2b1bc6bff/uv-0.11.14.tar.gz", hash = "sha256:0ea006a117b586b2681b6dfd9703a540d2ad2a136ec0f48d272767e599cc3dfb", size = 4130699, upload-time = "2026-05-12T18:00:37.321Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/99/84/dcb676a3e36a3a2b44dc2e4dfea471b8cd709025e27cce3e588b176fd899/uv-0.11.8-py3-none-linux_armv6l.whl", hash = "sha256:a53e704a780a9e78a50f5a880e99a690f84e6fb9e82610903ce26f47c271d74c", size = 23664296, upload-time = "2026-04-27T13:15:15.644Z" },
|
{ url = "https://files.pythonhosted.org/packages/f7/15/9b2138b16eb1fa8c2cd84b1037ad10c38b3acc36ce96c6d27000bfb7e716/uv-0.11.14-py3-none-linux_armv6l.whl", hash = "sha256:78411a883f230a710af19f2ac6e6f0ba8eae90f0e5af4605f923fd367539fff4", size = 23545199, upload-time = "2026-05-12T18:01:34.526Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/86/05/557aa070fda7b8460bbbe1e867e8e5b80602c5b30ed77d1d94fc5acae518/uv-0.11.8-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d414fc3795b6f56fb6b1fa359537930924fdfe857750a144d2aedf3077be3f1d", size = 23087321, upload-time = "2026-04-27T13:15:36.193Z" },
|
{ url = "https://files.pythonhosted.org/packages/75/81/c678e8b9a8e624f9c338c66cd57dd9cfc6b5a0501ad3c87fd0cc0bf8850a/uv-0.11.14-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:078f2e63da89c8fcf6d578f02156045c5990c57d76464aab3f3f798d3fff95cd", size = 22957064, upload-time = "2026-05-12T18:00:54.225Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d5/62/82953018801a250e16b091ef4b5e95e939b2f01224363d6fc80f600b7eff/uv-0.11.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f0d402e182ab581e934c159cc9edf25ec6e08d32f29aa797980e949afefc87cd", size = 21747142, upload-time = "2026-04-27T13:15:20.4Z" },
|
{ url = "https://files.pythonhosted.org/packages/f7/ad/95fbd15b23f26f36d0cfb0ddf159b9602a1b1c0feced60a7f98385e919f1/uv-0.11.14-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dcdad43d52c130e3159e84ab1844e04d819d2c4a2495a687d27f80d560a3650e", size = 21678307, upload-time = "2026-05-12T18:00:57.132Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/af/4c/477f2abe16f9a3d3c73077f15615878a303eef3760115ec946be58ecb9b2/uv-0.11.8-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:877c9af3b3955a35ef739e5b2ba79c56dae5c4d50420a7ed908c0901e1c8c807", size = 23425861, upload-time = "2026-04-27T13:15:10.374Z" },
|
{ url = "https://files.pythonhosted.org/packages/8b/cb/b3da1c4d95d6dd507896bca16dbd643118013b2b151f5f35a08d3391728c/uv-0.11.14-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:9923da7c63d70de9fe71829503d7e7ebfd6304e804d7232aad5f716e190db25b", size = 23353409, upload-time = "2026-05-12T18:01:27.512Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/2a/63/19f46193e49f0c9bf33346a4d726313871864db16e7cdd1c0a63bc112000/uv-0.11.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:8278144df8d80a83f770c264a5e79ea50791316d2a0dda869e53b3c1174142a8", size = 23215551, upload-time = "2026-04-27T13:15:38.706Z" },
|
{ url = "https://files.pythonhosted.org/packages/51/ad/78c6b8d6bcc04c5043b50631e9b413422a03a0bd7c4a997748f8e9cbac25/uv-0.11.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:3b0759ca504e48dcd4fafb1a61ef69aeb24c5a60fbf5f504a7873c8db1b24718", size = 23103964, upload-time = "2026-05-12T18:01:31.094Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/72/3e/5595b265df848a33cd060b10e8f763a46d67521ac9f6c314e8a4ad5329d7/uv-0.11.8-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3494ad32465f4e02259cfb104d24efe5bb8f7a782351f0354de9385415fb310", size = 23224170, upload-time = "2026-04-27T13:15:18.083Z" },
|
{ url = "https://files.pythonhosted.org/packages/0f/7d/acb66e09bc54a74e4288e996d841af04d88588fd6bdbfbab2468ab7169a7/uv-0.11.14-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:78b51b117549ee4db7197ea5ece0848cecd443e464fb9dff9f254cdc1e4ed96f", size = 23104638, upload-time = "2026-05-12T18:01:10.093Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/a6/b3/6ca95e690b52542caa1dae10ede57732f90c629946ab5f027ff746f87deb/uv-0.11.8-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a4421e27e81f85bce3bdb75986c38b5f9bfab9cdccaf3d977cf124b3f0f0b989", size = 24730048, upload-time = "2026-04-27T13:15:13.254Z" },
|
{ url = "https://files.pythonhosted.org/packages/31/0a/8497be61accdb8e56d02e11edd3ac471466259420e0bd9c05c1966df134a/uv-0.11.14-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a1ddbe8a2ab160affc179e9c3a40913b23a08cdf55254e1f3829cc22a51a0d8d", size = 24625888, upload-time = "2026-05-12T18:01:17.192Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ea/49/71b7322067c85a3736a22a300072b0566991fe3f95b81bed793508ff5315/uv-0.11.8-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91943e77fc962752d4f64ad5739219858395981078051c740b28b52963b366aa", size = 25585906, upload-time = "2026-04-27T13:15:41.455Z" },
|
{ url = "https://files.pythonhosted.org/packages/95/91/f730799fd20a45777b255e20cf9f648a4e4e0979bf65e87a8633197cf7d9/uv-0.11.14-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f3005a2db1e8d72e125630d4f22ac4ceddb2c033e1f9b94b7f3ea38ebac46dd6", size = 25445231, upload-time = "2026-05-12T18:00:40.012Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/37/16/4e84cd5131327fe86d4784ebfc8a983149f4e6b811476ef271fc548b29e6/uv-0.11.8-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:41fbba287efcc9bc9505a60549b3a223220da720eacd03be8c23d9daaafa44f4", size = 24795740, upload-time = "2026-04-27T13:15:49.842Z" },
|
{ url = "https://files.pythonhosted.org/packages/f5/4d/106463fc27e63e402aec2e791774dac2db5bd5e1c36cdcf38125aa97ab1c/uv-0.11.14-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d5c8f9ea36274ef2f9d24f0522085e280844172e901d9213f66a21b212266706", size = 24571961, upload-time = "2026-05-12T18:00:43.713Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/5b/01/df175979018743cc5ba6e2fb9dcec916868271e8d88cf0b9df8fd805a0df/uv-0.11.8-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d97bb2920d6cddc07faa475013461294cc09b77ec8139278416c6e54b938d037", size = 24824980, upload-time = "2026-04-27T13:15:53.506Z" },
|
{ url = "https://files.pythonhosted.org/packages/12/4d/163fe746b97bd1129627e8b1f943e17583ddc143eaab532d56a799a9ba5a/uv-0.11.14-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:379e64b236cf55f762a8308d7efe4365d5296ba29f3a4868761bc45b4e915a71", size = 24718523, upload-time = "2026-05-12T18:01:06.587Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/1c/95/93c7f595f7136fb32807442860c55d0faed2cd3d7da4b7105ed3c2535d5f/uv-0.11.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:fb6a755305eb1e081dfe6a8bc007dbae2d26fe75e551656ca7c9cd08fba21d26", size = 23526790, upload-time = "2026-04-27T13:15:04.955Z" },
|
{ url = "https://files.pythonhosted.org/packages/19/fb/7a3673494a0cf70267559166398f9c50c4925ff20122f99a28d6c5a80d83/uv-0.11.14-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:29c12a562441fc2d604e6920c558cacce74a55f889468708683a79b35a6e18a1", size = 23454821, upload-time = "2026-05-12T18:00:51.166Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/04/02/77430b89e172c20cc549b07a5b1dfda0c882c161b6d82781d3150a7063ac/uv-0.11.8-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:841ecbb38532698f73b14b49dc5f0c5e756194c7fcf6e5c6b7ed3859200fe91b", size = 24280498, upload-time = "2026-04-27T13:15:43.978Z" },
|
{ url = "https://files.pythonhosted.org/packages/bb/43/6358394a567d865f3a5ce27b1e0d939549911e36d9b59f0c545a167f92f7/uv-0.11.14-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:e84069681c0334e07cbc7f114eb09d7fe1335e1db0297a66dbca80a1b393fe6d", size = 24087843, upload-time = "2026-05-12T18:00:47.272Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/8a/e3/23e4a2bb91e3880e017e6116886e2d0bde14ba6aa95ddc458160ee630e7c/uv-0.11.8-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b3ff2b20c1897105ebe7ed7f9b1b331c7171da029bc1e35970ce31dc086141c1", size = 24375233, upload-time = "2026-04-27T13:15:25.753Z" },
|
{ url = "https://files.pythonhosted.org/packages/ef/f6/7d0ae1e1f52b85057ca24d8876d6a4cc87b541ea6aca627fe36594c06099/uv-0.11.14-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b15bf7c146e38d7c938d3a207115d5fdd8ef764fe1f866c225b1bed27e88da1e", size = 24147611, upload-time = "2026-05-12T18:01:20.499Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d9/67/fb7dc17cea816a667d1be2632525aa1687566bfafd17bdac561a7a6c9484/uv-0.11.8-py3-none-musllinux_1_1_i686.whl", hash = "sha256:ad381228b0170ef9646902c7e908d4a10a7ecc3da8139450506cf70c7e7f3e80", size = 23904818, upload-time = "2026-04-27T13:15:23.21Z" },
|
{ url = "https://files.pythonhosted.org/packages/5a/a2/511ad0c5da5697fd990b99569425b62b81cbc3458c35acc845211b55d6b5/uv-0.11.14-py3-none-musllinux_1_1_i686.whl", hash = "sha256:ddda5c5e41097814adac535c74851bae55e8097b9afc79aeae7fcffd8d86c06d", size = 23920348, upload-time = "2026-05-12T18:01:24.033Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/91/b920e35f54f8c6b51f2c639e8170bb80a47a739a1442fea33a479bc93a3d/uv-0.11.8-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:0172b5215544844cd3db0fa3c73a2eb74999b3f00cd2527dde578725076d7b65", size = 25015448, upload-time = "2026-04-27T13:15:46.666Z" },
|
{ url = "https://files.pythonhosted.org/packages/6b/b6/7084e3401b1f1020f215a125136eec1ed2bd541e10a5fea1625515579599/uv-0.11.14-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:e54326703f1eca83a6fd73275e0f398b16b7d3f81531bf58899c2869bc403f6c", size = 24928981, upload-time = "2026-05-12T18:01:13.961Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/05/e8/3771956dc1c94b8484789bb8070d91872080d0af99332b8bdec7218c2bfd/uv-0.11.8-py3-none-win32.whl", hash = "sha256:e71c1dd23cbb480f3952c3a95b4fd00f96bd618e2a94583fc9388c500af3070d", size = 22823583, upload-time = "2026-04-27T13:15:33.674Z" },
|
{ url = "https://files.pythonhosted.org/packages/4d/6a/7e81729fe729889c8cc63bbf64291734359bd7f6ba84852dc0504453511d/uv-0.11.14-py3-none-win32.whl", hash = "sha256:b384d873d0d18552c7524226125efd3965d921b7134c2f476c333771beb733e1", size = 22573503, upload-time = "2026-05-12T18:00:34.36Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/f9/9b/a91a9c60dcae0e1e3da06377d38f32118a523697d461fe41bc9f117ecf59/uv-0.11.8-py3-none-win_amd64.whl", hash = "sha256:306c624c68d95dd7ea3647675323d72c1abc25f91c3e92ae4cd6f0f11b508726", size = 25407438, upload-time = "2026-04-27T13:15:28.957Z" },
|
{ url = "https://files.pythonhosted.org/packages/94/5d/f8905f9af5cd46af2a688b2246dbb5a4d95b8557eeffd7f241e037659d9e/uv-0.11.14-py3-none-win_amd64.whl", hash = "sha256:f0a8b58b38e984241bca5d7a5a47bf9ffe1ca2ab392a640887db8a04c4a9ec95", size = 25175590, upload-time = "2026-05-12T18:01:00.38Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/61/5d/defa29fe617e6f07d4e514089e9d36fd9f44ede869e597e39ff7d69f6917/uv-0.11.8-py3-none-win_arm64.whl", hash = "sha256:a9853456696d579f206135c9dda7227a6ed8311b8a9a0b9b2008c4ae81950efe", size = 23914243, upload-time = "2026-04-27T13:15:07.717Z" },
|
{ url = "https://files.pythonhosted.org/packages/04/cb/7333d08d944f3018eb89242cd5e646e7b37faa1b567faeaf9254a8b59d53/uv-0.11.14-py3-none-win_arm64.whl", hash = "sha256:6a13e7e064563050c6606b3fd77091d427cdbdc5938b6f134baf8d8ec79bfdb7", size = 23594775, upload-time = "2026-05-12T18:01:03.55Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "virtualenv"
|
name = "virtualenv"
|
||||||
version = "21.3.0"
|
version = "21.3.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "distlib" },
|
{ name = "distlib" },
|
||||||
@@ -1604,18 +1647,18 @@ dependencies = [
|
|||||||
{ name = "platformdirs" },
|
{ name = "platformdirs" },
|
||||||
{ name = "python-discovery" },
|
{ name = "python-discovery" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/3f/8b/6331f7a7fe70131c301106ec1e7cf23e2501bf7d4ca3636805801ca191bb/virtualenv-21.3.0.tar.gz", hash = "sha256:733750db978ec95c2d8eb4feadaa57091002bce404cb39ba69899cf7bd28944e", size = 7614069, upload-time = "2026-04-27T17:05:58.927Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/4b/eb/03bfb1299d4c4510329e470f13f9a4ce793df7fcb5a2fd3510f911066f61/virtualenv-21.3.0-py3-none-any.whl", hash = "sha256:4d28ee41f6d9ec8f1f00cd472b9ffbcedda1b3d3b9a575b5c94a2d004fd51bd7", size = 7594690, upload-time = "2026-04-27T17:05:55.468Z" },
|
{ url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wcwidth"
|
name = "wcwidth"
|
||||||
version = "0.6.0"
|
version = "0.7.0"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" },
|
{ url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Reference in New Issue
Block a user