diff --git a/core/alerts/README.md b/core/alerts/README.md new file mode 100644 index 0000000..ba250d8 --- /dev/null +++ b/core/alerts/README.md @@ -0,0 +1,287 @@ +# Alerts System + +The alerts system monitors narratives and sends notifications via multiple channels (email, Slack) when specific thresholds or conditions are met. + +## Architecture + +- **Models** (`models.py`): Alert types, scopes, channel configs, and request/response models +- **Repository** (`repo.py`): Database access layer for alerts and triggered notifications +- **Service** (`service.py`): Business logic for processing alerts and sending notifications +- **Controller** (`controller.py`): HTTP endpoints for alert management + +## Alert Types + +- `NARRATIVE_VIEWS`: Alert when a narrative reaches a view count threshold +- `NARRATIVE_CLAIMS_COUNT`: Alert when a narrative reaches a claim count threshold +- `NARRATIVE_VIDEOS_COUNT`: Alert when a narrative reaches a video count threshold +- `NARRATIVE_WITH_TOPIC`: Alert when new narratives are tagged with a specific topic +- `KEYWORD`: Alert when narratives mention a specific keyword + +## Alert Scopes + +- `GENERAL`: Monitor all narratives in the organization +- `SPECIFIC`: Monitor a single narrative (requires `narrative_id`) + +## Notification Channels + +Alerts support multiple notification channels configured via the `channels` field: + +### Email Channel +```json +{ + "channel_type": "email" +} +``` +Sends notification to the alert owner's email address. + +### Slack Channel +```json +{ + "channel_type": "slack", + "slack_channel_id": "C12345678" +} +``` +Sends notification to a specific Slack channel. Requires: +- Organization must have Slack installed (see [Slack Integration](../integrations/slack/README.md)) +- `slack_channel_id` must match a channel from an existing Slack installation + +**Note**: You can get the `slack_channel_id` from the `/integrations/slack/installations` endpoint. + +### Multiple Channels +Alerts can be configured to send to multiple channels simultaneously: + +```json +{ + "channels": [ + {"channel_type": "email"}, + {"channel_type": "slack", "slack_channel_id": "C12345678"} + ] +} +``` + +## Database Schema + +### `alerts` Table +- `id`: UUID primary key +- `user_id`: User who created the alert +- `organisation_id`: Organisation the alert belongs to +- `name`: Human-readable alert name +- `alert_type`: Type of alert (enum) +- `scope`: General or specific (enum) +- `narrative_id`: Required for specific scope +- `threshold`: Required for view/claim/video count alerts +- `topic_id`: Required for topic alerts +- `keyword`: Required for keyword alerts +- `enabled`: Boolean to enable/disable alerts +- `channels`: JSONB array of channel configurations (NOT NULL) +- `metadata`: JSONB for additional data +- `created_at`, `updated_at`: Timestamps + +**Constraints**: +- `channels` must not be an empty array (`jsonb_array_length(channels) > 0`) +- Default value: `[{"channel_type": "email"}]` +- Various CHECK constraints for alert type validation + +### `alerts_triggered` Table +Tracks when alerts are triggered and notification delivery status. + +- `id`: UUID primary key +- `alert_id`: Foreign key to alerts +- `narrative_id`: Narrative that triggered the alert +- `triggered_at`: Timestamp +- `trigger_value`: Value that triggered the alert +- `threshold_crossed`: Threshold value +- `notification_sent`: Boolean (deprecated, use `notification_status`) +- `notification_status`: JSONB tracking per-channel delivery + ```json + { + "email": "sent", + "slack": "sent" + } + ``` +- `metadata`: Additional data + +### `alert_executions` Table +Tracks alert processing runs. + +- `id`: UUID primary key +- `executed_at`: Timestamp +- `alerts_checked`: Count of alerts checked +- `alerts_triggered`: Count of alerts triggered +- `emails_sent`: Count of emails sent +- `metadata`: Execution stats + +## API Usage + +### Create an Alert + +#### Email-only Alert (Default) +```http +POST /alerts +Authorization: Bearer +Content-Type: application/json + +{ + "name": "High View Count Alert", + "alert_type": "narrative_views", + "scope": "general", + "threshold": 100000 +} +``` + +#### Slack-only Alert +```http +POST /alerts +Authorization: Bearer +Content-Type: application/json + +{ + "name": "Climate Topic Alert", + "alert_type": "narrative_with_topic", + "scope": "general", + "topic_id": "456e7890-e89b-12d3-a456-426614174000", + "channels": [ + {"channel_type": "slack", "slack_channel_id": "C87654321"} + ] +} +``` + +#### Multi-channel Alert +```http +POST /alerts +Authorization: Bearer +Content-Type: application/json + +{ + "name": "Critical Claims Alert", + "alert_type": "narrative_claims_count", + "scope": "specific", + "narrative_id": "123e4567-e89b-12d3-a456-426614174000", + "threshold": 50, + "channels": [ + {"channel_type": "email"}, + {"channel_type": "slack", "slack_channel_id": "C12345678"} + ] +} +``` + +### Get User Alerts +```http +GET /alerts?enabled_only=true&limit=50&offset=0 +Authorization: Bearer +``` + +Returns paginated list of alerts with their channel configurations. + +### Get Alert by ID +```http +GET /alerts/{alert_id} +Authorization: Bearer +``` + +### Update Alert +```http +PATCH /alerts/{alert_id} +Authorization: Bearer +Content-Type: application/json + +{ + "name": "Updated Alert Name", + "enabled": false, + "channels": [{"channel_type": "email"}] +} +``` + +### Delete Alert +```http +DELETE /alerts/{alert_id} +Authorization: Bearer +``` + +## Alert Processing + +Alerts are processed periodically by the background service: + +1. **Check Alerts**: Query enabled alerts and check if their conditions are met +2. **Trigger Alerts**: Record triggered alerts in `alerts_triggered` table +3. **Send Notifications**: + - Group alerts by (user_id, organisation_id) + - Separate by channel type (email, Slack) + - Send notifications independently per channel + - Track delivery status in `notification_status` JSONB field +4. **Record Execution**: Log statistics in `alert_executions` + +### Notification Delivery + +- **Email**: Sent to alert owner's email address via configured email service +- **Slack**: Sent to specified channel using organization's Slack bot token +- **Failure Handling**: Each channel delivery is independent - Slack failure won't block email delivery + +## Setup for Slack Notifications + +To use Slack notifications, organizations must: + +1. Install the Slack app via `/integrations/slack/install-url` +2. Complete OAuth flow (redirects to `/integrations/slack/oauth/callback`) +3. Get the `slack_channel_id` from `/integrations/slack/installations` +4. Use the channel ID when creating alerts + +See [Slack Integration README](../integrations/slack/README.md) for details. + +## Examples + +### Example Response from GET /alerts +```json +{ + "data": [ + { + "id": "24ad2de3-db26-4bd3-aacb-261214fd02a3", + "user_id": "f1234567-e89b-12d3-a456-426614174000", + "organisation_id": "a1234567-e89b-12d3-a456-426614174000", + "name": "Development Alert", + "alert_type": "narrative_views", + "scope": "general", + "threshold": 10000, + "enabled": true, + "channels": [ + {"channel_type": "email"}, + {"channel_type": "slack", "slack_channel_id": "C0AMLPMBTGR"} + ], + "metadata": {}, + "created_at": "2026-03-25T10:00:00Z", + "updated_at": "2026-03-25T10:00:00Z" + } + ], + "total": 1, + "page": 1, + "size": 50 +} +``` + +### Example Notification Status +After an alert is triggered and notifications are sent: + +```json +{ + "id": "t1234567-e89b-12d3-a456-426614174000", + "alert_id": "24ad2de3-db26-4bd3-aacb-261214fd02a3", + "narrative_id": "n1234567-e89b-12d3-a456-426614174000", + "triggered_at": "2026-03-25T12:30:00Z", + "trigger_value": 15000, + "threshold_crossed": 10000, + "notification_status": { + "email": "sent", + "slack": "sent" + }, + "metadata": {} +} +``` + +## Migration History + +- **Migration 11**: Initial alerts tables (alerts, alerts_triggered, alert_executions) +- **Migration 18**: + - Added `notification_status` JSONB to `alerts_triggered` + - Added `channels` JSONB to `alerts` with default `[{"channel_type": "email"}]` + - Added constraint: `channels` must not be empty + - Migrated existing channel configs from metadata to dedicated column diff --git a/core/alerts/controller.py b/core/alerts/controller.py index ebc4213..42b8b3b 100644 --- a/core/alerts/controller.py +++ b/core/alerts/controller.py @@ -48,6 +48,7 @@ async def create_alert( threshold=data.threshold, topic_id=data.topic_id, keyword=data.keyword, + channels=[channel.model_dump() for channel in data.channels], metadata=data.metadata, ) @@ -109,6 +110,7 @@ async def update_alert( enabled=data.enabled, threshold=data.threshold, keyword=data.keyword, + channels=[channel.model_dump() for channel in data.channels] if data.channels is not None else None, ) @delete( diff --git a/core/alerts/models.py b/core/alerts/models.py index f979b34..d4e6334 100644 --- a/core/alerts/models.py +++ b/core/alerts/models.py @@ -19,6 +19,26 @@ class AlertScope(str, Enum): SPECIFIC = "specific" +class ChannelType(str, Enum): + """Notification channel types for alerts""" + EMAIL = "email" + SLACK = "slack" + + +class ChannelConfig(BaseModel): + """Configuration for a notification channel""" + channel_type: ChannelType + slack_channel_id: str | None = None + + @model_validator(mode='after') + def validate_slack_config(self) -> 'ChannelConfig': + if self.channel_type == ChannelType.SLACK and not self.slack_channel_id: + raise ValueError("slack_channel_id is required for Slack channels") + if self.channel_type == ChannelType.EMAIL and self.slack_channel_id: + raise ValueError("slack_channel_id should not be provided for email channels") + return self + + class Alert(BaseModel): id: UUID = Field(default_factory=uuid4) user_id: UUID @@ -31,9 +51,36 @@ class Alert(BaseModel): topic_id: UUID | None = None keyword: str | None = None enabled: bool = True + channels: list[dict[str, Any]] = Field( + default_factory=lambda: [{"channel_type": "email"}] + ) metadata: dict[str, Any] = {} created_at: datetime | None = None updated_at: datetime | None = None + + @property + def channel_configs(self) -> list[dict[str, Any]]: + """Get configured notification channels with their configs""" + return self.channels + + @property + def has_email_channel(self) -> bool: + """Check if alert is configured for email notifications""" + return any(c.get("channel_type") == "email" for c in self.channels) + + @property + def has_slack_channel(self) -> bool: + """Check if alert is configured for Slack notifications""" + return any(c.get("channel_type") == "slack" for c in self.channels) + + @property + def slack_channel_ids(self) -> list[str]: + """Get all configured Slack channel IDs""" + return [ + c["slack_channel_id"] + for c in self.channels + if c.get("channel_type") == "slack" and "slack_channel_id" in c + ] class AlertExecution(BaseModel): @@ -41,7 +88,7 @@ class AlertExecution(BaseModel): executed_at: datetime alerts_checked: int alerts_triggered: int - emails_sent: int + notifications_sent: int metadata: dict[str, Any] = {} @@ -52,7 +99,8 @@ class AlertTriggered(BaseModel): triggered_at: datetime trigger_value: int | None = None threshold_crossed: int | None = None - notification_sent: bool = False + notification_sent: bool = False # Deprecated: use notification_status instead + notification_status: dict[str, str] = {} # {"email": "sent", "slack": "failed"} metadata: dict[str, Any] = {} @@ -65,6 +113,7 @@ class CreateAlertRequest(BaseModel): "alert_type": "narrative_views", "scope": "general", "threshold": 100000, + "channels": [{"channel_type": "email"}], "metadata": {"description": "Alert when any narrative exceeds 100000 views"} }, { @@ -73,6 +122,10 @@ class CreateAlertRequest(BaseModel): "scope": "specific", "narrative_id": "123e4567-e89b-12d3-a456-426614174000", "threshold": 50, + "channels": [ + {"channel_type": "email"}, + {"channel_type": "slack", "slack_channel_id": "C12345678"} + ], "metadata": {"description": "Alert when specific narrative has 50+ claims"} }, { @@ -80,6 +133,7 @@ class CreateAlertRequest(BaseModel): "alert_type": "narrative_with_topic", "scope": "general", "topic_id": "456e7890-e89b-12d3-a456-426614174000", + "channels": [{"channel_type": "slack", "slack_channel_id": "C87654321"}], "metadata": {"description": "Alert for new narratives with climate topic"} }, { @@ -87,6 +141,7 @@ class CreateAlertRequest(BaseModel): "alert_type": "keyword", "scope": "general", "keyword": "vaccine", + "channels": [{"channel_type": "email"}], "metadata": {"description": "Alert when narratives mention 'vaccine'"} } ] @@ -101,6 +156,10 @@ class CreateAlertRequest(BaseModel): topic_id: UUID | None = Field(None, description="Required for narrative_with_topic alerts only") keyword: str | None = Field(None, description="Required for keyword alerts only", min_length=1, max_length=255) metadata: dict[str, Any] = Field(default_factory=dict, description="Additional metadata for the alert") + channels: list[ChannelConfig] = Field( + default_factory=lambda: [ChannelConfig(channel_type=ChannelType.EMAIL)], + description="Notification channels for this alert (email, Slack, or both)" + ) @model_validator(mode='after') def validate_alert_fields(self): @@ -173,4 +232,8 @@ class UpdateAlertRequest(BaseModel): enabled: bool | None = Field(None, description="Enable or disable the alert") threshold: int | None = Field(None, description="New threshold value", ge=1) keyword: str | None = Field(None, description="New keyword to search for", min_length=1, max_length=255) + channels: list[ChannelConfig] | None = Field( + None, + description="Updated notification channels for this alert (email, Slack, or both). If not provided, channels will not be updated." + ) metadata: dict[str, Any] = {} \ No newline at end of file diff --git a/core/alerts/repo.py b/core/alerts/repo.py index 9be4847..0d64da1 100644 --- a/core/alerts/repo.py +++ b/core/alerts/repo.py @@ -25,6 +25,7 @@ async def create_alert( threshold: int | None = None, topic_id: UUID | None = None, keyword: str | None = None, + channels: list[dict[str, Any]] | None = None, metadata: dict[str, Any] | None = None, ) -> Alert: try: @@ -32,10 +33,10 @@ async def create_alert( """ INSERT INTO alerts ( user_id, organisation_id, name, alert_type, scope, - narrative_id, threshold, topic_id, keyword, metadata + narrative_id, threshold, topic_id, keyword, channels, metadata ) VALUES ( %(user_id)s, %(organisation_id)s, %(name)s, %(alert_type)s, %(scope)s, - %(narrative_id)s, %(threshold)s, %(topic_id)s, %(keyword)s, %(metadata)s + %(narrative_id)s, %(threshold)s, %(topic_id)s, %(keyword)s, %(channels)s, %(metadata)s ) RETURNING * """, @@ -49,6 +50,7 @@ async def create_alert( "threshold": threshold, "topic_id": topic_id, "keyword": keyword, + "channels": Jsonb(channels or [{"channel_type": "email"}]), "metadata": Jsonb(metadata or {}), }, ) @@ -115,9 +117,10 @@ async def update_alert( enabled: bool | None = None, threshold: int | None = None, keyword: str | None = None, + channels: list[dict[str, Any]] | None = None, ) -> Alert | None: updates = [] - params: dict[str, UUID | str | bool | int] = {"alert_id": alert_id} + params: dict[str, UUID | str | bool | int | list[dict[str, Any]] | Jsonb] = {"alert_id": alert_id} if name is not None: updates.append("name = %(name)s") @@ -135,6 +138,10 @@ async def update_alert( updates.append("keyword = %(keyword)s") params["keyword"] = keyword + if channels is not None: + updates.append("channels = %(channels)s") + params["channels"] = Jsonb(channels) + if not updates: return await self.get_alert(alert_id) @@ -213,6 +220,37 @@ async def mark_notification_sent(self, triggered_id: UUID) -> bool: ) return self._session.rowcount > 0 + async def record_channel_delivery( + self, + triggered_id: UUID, + channel: str, + status: str + ) -> bool: + """ + Record delivery status for a specific notification channel. + Updates the notification_status JSONB field with channel-specific status. + + Args: + triggered_id: ID of the triggered alert + channel: Channel type (e.g., "email", "slack") + status: Delivery status (e.g., "sent", "failed", "skipped") + + Returns: + True if update was successful + """ + await self._session.execute( + """ + UPDATE alerts_triggered + SET notification_status = notification_status || %(status_update)s + WHERE id = %(triggered_id)s + """, + { + "triggered_id": triggered_id, + "status_update": Jsonb({channel: status}), + }, + ) + return self._session.rowcount > 0 + async def get_pending_notifications( self, since: datetime | None = None ) -> list[AlertTriggered]: @@ -238,22 +276,22 @@ async def record_execution( self, alerts_checked: int, alerts_triggered: int, - emails_sent: int, + notifications_sent: int, metadata: dict[str, Any] | None = None, ) -> AlertExecution: await self._session.execute( """ INSERT INTO alert_executions ( - alerts_checked, alerts_triggered, emails_sent, metadata + alerts_checked, alerts_triggered, notifications_sent, metadata ) VALUES ( - %(alerts_checked)s, %(alerts_triggered)s, %(emails_sent)s, %(metadata)s + %(alerts_checked)s, %(alerts_triggered)s, %(notifications_sent)s, %(metadata)s ) RETURNING * """, { "alerts_checked": alerts_checked, "alerts_triggered": alerts_triggered, - "emails_sent": emails_sent, + "notifications_sent": notifications_sent, "metadata": Jsonb(metadata or {}), }, ) @@ -319,6 +357,7 @@ async def check_narrative_stats_alerts( a.topic_id, a.keyword, a.enabled, + a.channels, a.metadata, a.created_at, a.updated_at @@ -372,6 +411,7 @@ async def check_narrative_stats_alerts( topic_id=row["topic_id"], keyword=row["keyword"], enabled=row["enabled"], + channels=row["channels"], metadata=row["metadata"], created_at=row["created_at"], updated_at=row["updated_at"], @@ -389,7 +429,7 @@ async def check_topic_alerts( query = """ SELECT DISTINCT a.id, a.user_id, a.organisation_id, a.name, a.alert_type, a.scope, a.threshold, a.topic_id, a.keyword, a.enabled, - a.metadata, a.created_at, a.updated_at, nt.narrative_id + a.channels, a.metadata, a.created_at, a.updated_at, nt.narrative_id FROM alerts a JOIN narrative_topics nt ON a.topic_id = nt.topic_id JOIN narratives n ON nt.narrative_id = n.id @@ -424,6 +464,7 @@ async def check_topic_alerts( topic_id=row["topic_id"], keyword=row["keyword"], enabled=row["enabled"], + channels=row["channels"], metadata=row["metadata"], created_at=row["created_at"], updated_at=row["updated_at"], @@ -454,7 +495,7 @@ async def check_keyword_alerts( ) SELECT DISTINCT a.id, a.user_id, a.organisation_id, a.name, a.alert_type, a.scope, a.threshold, a.topic_id, a.keyword, a.enabled, - a.metadata, a.created_at, a.updated_at, n.id as narrative_id + a.channels, a.metadata, a.created_at, a.updated_at, n.id as narrative_id FROM alerts a JOIN recent_narratives n ON ( LOWER(n.title) LIKE LOWER('%%' || a.keyword || '%%') @@ -482,6 +523,7 @@ async def check_keyword_alerts( topic_id=row["topic_id"], keyword=row["keyword"], enabled=row["enabled"], + channels=row["channels"], metadata=row["metadata"], created_at=row["created_at"], updated_at=row["updated_at"], diff --git a/core/alerts/service.py b/core/alerts/service.py index b2b0ad0..a61518c 100644 --- a/core/alerts/service.py +++ b/core/alerts/service.py @@ -6,7 +6,7 @@ from psycopg import AsyncConnection from psycopg.rows import DictRow -from core.alerts.models import Alert, AlertExecution, AlertScope, AlertType +from core.alerts.models import Alert, AlertExecution, AlertScope, AlertType, ChannelType from core.alerts.repo import AlertRepository from core.auth.models import Identity from core.auth.repo import AuthRepository @@ -14,7 +14,7 @@ from core.email.messages import alerts_message from core.errors import NotAuthorizedError, NotFoundError from core.narratives.repo import NarrativeRepository - +from core.config import APP_BASE_URL class AlertService: def __init__( @@ -33,6 +33,7 @@ async def create_alert( threshold: int | None = None, topic_id: UUID | None = None, keyword: str | None = None, + channels: list[dict[str, Any]] | None = None, metadata: dict[str, Any] | None = None, ) -> Alert: if not identity.organisation: @@ -72,6 +73,7 @@ async def create_alert( threshold=threshold, topic_id=topic_id, keyword=keyword, + channels=channels, metadata=metadata, ) @@ -118,6 +120,7 @@ async def update_alert( enabled: bool | None = None, threshold: int | None = None, keyword: str | None = None, + channels: list[dict[str, Any]] | None = None, ) -> Alert: alert = await self.get_alert(alert_id, identity) @@ -130,6 +133,7 @@ async def update_alert( enabled=enabled, threshold=threshold, keyword=keyword, + channels=channels, ) if not updated: @@ -210,17 +214,21 @@ async def process_alerts(self) -> AlertExecution: alerts_triggered += 1 triggered_alerts.append((alert, triggered, narrative_id)) - emails_sent = await self._send_alert_notifications( + emails_sent, slack_messages_sent = await self._send_alert_notifications( triggered_alerts, alert_repo, auth_repo, narrative_repo ) + + notifications_sent = emails_sent + slack_messages_sent execution = await alert_repo.record_execution( alerts_checked=alerts_checked, alerts_triggered=alerts_triggered, - emails_sent=emails_sent, + notifications_sent=notifications_sent, metadata={ "since": since.isoformat(), "completed_at": datetime.now(timezone.utc).isoformat(), + "emails_sent": emails_sent, + "slack_messages_sent": slack_messages_sent, }, ) @@ -232,9 +240,14 @@ async def _send_alert_notifications( alert_repo: AlertRepository, auth_repo: AuthRepository, narrative_repo: NarrativeRepository, - ) -> int: - """Send email notifications for triggered alerts.""" + ) -> tuple[int, int]: + """Send notifications for triggered alerts via configured channels (email and/or Slack). + Returns: + Tuple of (emails_sent, slack_messages_sent) + """ + + # Group alerts by (user_id, org_id) user_alerts = defaultdict(list) for alert, triggered, narrative_id in triggered_alerts: @@ -242,50 +255,204 @@ async def _send_alert_notifications( user_alerts[key].append((alert, triggered, narrative_id)) emails_sent = 0 + slack_messages_sent = 0 for (user_id, org_id), alerts in user_alerts.items(): - user = await auth_repo.get_user_by_id(user_id) - org = await auth_repo.get_organisation(org_id) + # Separate alerts by channel type + email_alerts = [a for a in alerts if a[0].has_email_channel] + slack_alerts = [a for a in alerts if a[0].has_slack_channel] - if not user or not org: - continue + # Send email notifications + if email_alerts: + sent = await self._send_email_notification( + user_id, org_id, email_alerts, alert_repo, auth_repo, narrative_repo + ) + emails_sent += sent + + # Send Slack notifications + if slack_alerts: + sent = await self._send_slack_notification( + org_id, slack_alerts, alert_repo, narrative_repo + ) + slack_messages_sent += sent - alert_details = [] - for alert, triggered, narrative_id in alerts: - narrative = await narrative_repo.get_narrative(narrative_id) - if narrative: - alert_details.append({ - "alert_name": alert.name, - "alert_type": alert.alert_type.value, - "narrative_title": narrative.title, - "narrative_id": str(narrative_id), - "trigger_value": triggered.trigger_value, - "threshold": alert.threshold, - "keyword": alert.keyword, - "triggered_at": triggered.triggered_at, - }) - - if alert_details: - subject, body = alerts_message( - organisation_name=org.display_name, - alerts=alert_details, - locale=org.language, + return emails_sent, slack_messages_sent + + async def _send_email_notification( + self, + user_id: UUID, + org_id: UUID, + alerts: list[tuple[Alert, Any, UUID]], + alert_repo: AlertRepository, + auth_repo: AuthRepository, + narrative_repo: NarrativeRepository, + ) -> int: + """Send email notification for a group of alerts.""" + user = await auth_repo.get_user_by_id(user_id) + org = await auth_repo.get_organisation(org_id) + + if not user or not org: + return 0 + + alert_details = [] + for alert, triggered, narrative_id in alerts: + narrative = await narrative_repo.get_narrative(narrative_id) + if narrative: + alert_details.append({ + "alert_name": alert.name, + "alert_type": alert.alert_type.value, + "narrative_title": narrative.title, + "narrative_id": str(narrative_id), + "trigger_value": triggered.trigger_value, + "threshold": alert.threshold, + "keyword": alert.keyword, + "triggered_at": triggered.triggered_at, + }) + + if not alert_details: + return 0 + + subject, body = alerts_message( + organisation_name=org.display_name, + alerts=alert_details, + locale=org.language, + ) + + try: + emailer = await get_emailer() + emailer.send( + to=user.email, + subject=subject, + html=body, + ) + + # Mark email delivery as successful + for _, triggered, _ in alerts: + await alert_repo.record_channel_delivery( + triggered.id, ChannelType.EMAIL.value, "sent" + ) + # Also mark legacy field for backward compatibility + await alert_repo.mark_notification_sent(triggered.id) + + return 1 + except Exception as e: + print(f"Warning: Failed to send email to {user.email}: {e}") + + # Mark email delivery as failed + for _, triggered, _ in alerts: + await alert_repo.record_channel_delivery( + triggered.id, ChannelType.EMAIL.value, "failed" + ) + + return 0 + + async def _send_slack_notification( + self, + org_id: UUID, + alerts: list[tuple[Alert, Any, UUID]], + alert_repo: AlertRepository, + narrative_repo: NarrativeRepository, + ) -> int: + """Send Slack notifications for a group of alerts. + + Returns: + Number of Slack messages successfully sent + """ + from core.integrations.slack.service import SlackService + + # Get Slack installations for this organization + slack_service = SlackService(self._connection_factory) + installations = await slack_service.get_installations_by_organisation(org_id) + + if not installations: + # No Slack configured for this organization + for alert, triggered, _ in alerts: + await alert_repo.record_channel_delivery( + triggered.id, ChannelType.SLACK.value, "skipped" + ) + return 0 + + # Group alerts by slack_channel_id + alerts_by_channel = defaultdict(list) + for alert, triggered, narrative_id in alerts: + for channel_id in alert.slack_channel_ids: + alerts_by_channel[channel_id].append((alert, triggered, narrative_id)) + + messages_sent = 0 + + # Send to each configured Slack channel + for channel_id, channel_alerts in alerts_by_channel.items(): + # Format message for this group of alerts + message = await self._format_slack_alert_message( + channel_alerts, narrative_repo + ) + + try: + # Send message using channel_id directly + await slack_service.send_message_to_slack( + organisation_id=org_id, + channel=channel_id, + text=message ) - try: - emailer = await get_emailer() - emailer.send( - to=user.email, - subject=subject, - html=body, + messages_sent += 1 + + # Mark as sent + for _, triggered, _ in channel_alerts: + await alert_repo.record_channel_delivery( + triggered.id, ChannelType.SLACK.value, "sent" ) - emails_sent += 1 - for _, triggered, _ in alerts: - await alert_repo.mark_notification_sent(triggered.id) - except Exception as e: - print(f"Warning: Failed to send email to {user.email}: {e}") - for _, triggered, _ in alerts: - await alert_repo.mark_notification_sent(triggered.id) - - return emails_sent \ No newline at end of file + except Exception as e: + print(f"Warning: Failed to send Slack notification to channel {channel_id}: {e}") + # Mark as failed + for _, triggered, _ in channel_alerts: + await alert_repo.record_channel_delivery( + triggered.id, ChannelType.SLACK.value, "failed" + ) + + return messages_sent + + async def _format_slack_alert_message( + self, + alerts: list[tuple[Alert, Any, UUID]], + narrative_repo: NarrativeRepository, + ) -> str: + """Format alerts into a Slack-friendly message (Markdown).""" + + lines = ["🔔 *Alert Notifications*\n"] + + for alert, triggered, narrative_id in alerts: + narrative = await narrative_repo.get_narrative(narrative_id) + if not narrative: + continue + + alert_name = alert.name + alert_type = alert.alert_type.value + narrative_title = narrative.title + + # Build description based on alert type (similar to email) + if alert_type == "narrative_views": + description = f"Narrative \"{narrative_title}\" reached {triggered.trigger_value:,} views (threshold: {alert.threshold:,})" + elif alert_type == "narrative_claims_count": + description = f"Narrative \"{narrative_title}\" reached {triggered.trigger_value} claims (threshold: {alert.threshold})" + elif alert_type == "narrative_videos_count": + description = f"Narrative \"{narrative_title}\" reached {triggered.trigger_value} videos (threshold: {alert.threshold})" + elif alert_type == "narrative_with_topic": + description = f"New narrative \"{narrative_title}\" created with tracked topic" + elif alert_type == "keyword": + description = f"Narrative \"{narrative_title}\" contains keyword \"{alert.keyword}\"" + else: + description = f"Alert triggered for narrative \"{narrative_title}\"" + + # Format alert item + lines.append(f"*{alert_name}*") + lines.append(f"_{alert_type.replace('_', ' ').title()}_") + lines.append(f"{description}") + + # Add link to narrative + narrative_url = f"{APP_BASE_URL}/narratives/{narrative_id}" + lines.append(f"<{narrative_url}|View Narrative →>") + lines.append("") # Empty line between alerts + + return "\n".join(lines) \ No newline at end of file diff --git a/core/app.py b/core/app.py index eed5415..022668c 100644 --- a/core/app.py +++ b/core/app.py @@ -28,8 +28,9 @@ from core.videos.claims.controller import ClaimController, RootClaimController from core.videos.controller import VideoController from core.videos.transcripts.controller import TranscriptController +from core.integrations.router import integrations_router -MIGRATION_TARGET_VERSION = 17 +MIGRATION_TARGET_VERSION = 18 postgres_url = f"postgresql://{config.DB_USER}:{config.DB_PASSWORD}@{config.DB_HOST}:{config.DB_PORT}/{config.DB_NAME}" @@ -107,6 +108,7 @@ async def health(state: State) -> str: NarrativeFeedbackController, ClaimNarrativeFeedbackController, LanguageController, + integrations_router ], ) diff --git a/core/cli.py b/core/cli.py index c4622bd..90cd759 100644 --- a/core/cli.py +++ b/core/cli.py @@ -108,7 +108,15 @@ async def main(): click.echo(f"Alerts processed successfully!") click.echo(f" - Alerts checked: {execution.alerts_checked}") click.echo(f" - Alerts triggered: {execution.alerts_triggered}") - click.echo(f" - Emails sent: {execution.emails_sent}") + click.echo(f" - Notifications sent: {execution.notifications_sent}") + + # Show detailed breakdown if available in metadata + if execution.metadata: + emails = execution.metadata.get("emails_sent", 0) + slack = execution.metadata.get("slack_messages_sent", 0) + if emails > 0 or slack > 0: + click.echo(f" · Emails: {emails}") + click.echo(f" · Slack messages: {slack}") except Exception as e: click.echo(f"Error processing alerts: {e}", err=True) diff --git a/core/config.py b/core/config.py index 09fbd9b..d2dafca 100644 --- a/core/config.py +++ b/core/config.py @@ -51,6 +51,10 @@ """narrative API settings""" NARRATIVES_BASE_URL = os.environ.get("NARRATIVES_BASE_ENDPOINT") NARRATIVES_API_KEY = os.environ.get("NARRATIVES_API_KEY") +"""slack settings""" +SLACK_CLIENT_ID = os.environ.get("SLACK_CLIENT_ID", "") +SLACK_CLIENT_SECRET = os.environ.get("SLACK_CLIENT_SECRET", "") +SLACK_REDIRECT_URI = os.environ.get("SLACK_REDIRECT_URI", "") """internationalisation""" i18n.set("file_format", "json") diff --git a/core/integrations/__init__.py b/core/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/integrations/router.py b/core/integrations/router.py new file mode 100644 index 0000000..06a6be3 --- /dev/null +++ b/core/integrations/router.py @@ -0,0 +1,10 @@ +from litestar import Router + +from core.integrations.slack.controller import SlackController + +integrations_router = Router( + path="/integrations", + route_handlers=[ + SlackController, + ], +) diff --git a/core/integrations/slack/README.md b/core/integrations/slack/README.md new file mode 100644 index 0000000..55f7e0b --- /dev/null +++ b/core/integrations/slack/README.md @@ -0,0 +1,257 @@ +# Slack Integration + +This module provides OAuth2 authentication and messaging capabilities for Slack workspaces, storing installation data in PostgreSQL instead of file-based storage. + +## Architecture + +The integration follows the repository pattern used throughout the project: + +- **Models** (`models.py`): Pydantic models for `SlackInstallation` and `SlackOAuthState` +- **Repository** (`repo.py`): Database access layer for managing installations and OAuth states +- **Service** (`service.py`): Business logic for OAuth flow and messaging +- **Controller** (`controller.py`): HTTP endpoints for installation and OAuth callback + +## Database Tables + +### `slack_oauth_states` +Temporary storage for OAuth state tokens (CSRF protection). States expire after 5 minutes. + +- `id`: UUID primary key +- `state`: Unique OAuth state token +- `organisation_id`: Organisation this installation is for +- `created_at`: Timestamp +- `expires_at`: Expiration timestamp + +### `slack_installations` +Persistent storage for Slack workspace installations. + +- `id`: UUID primary key +- `organisation_id`: Foreign key to organisations table +- `team_id`: Slack workspace ID +- `team_name`: Slack workspace name +- `bot_token`: Bot token for API calls (sensitive!) +- `incoming_webhook_url`: URL for posting messages +- `incoming_webhook_channel`: Default channel name +- `incoming_webhook_channel_id`: Default channel ID +- Additional fields for enterprise installations, user tokens, etc. +- `created_at`, `updated_at`: Timestamps + +**Constraint**: One installation per (organisation_id, incoming_webhook_channel_id) pair. Reinstalling updates the existing record. + +## Usage + +### Installing Slack for an Organisation + +1. **Generate installation URL** (requires authentication): + ```http + GET /integrations/slack/install-url + Authorization: Bearer + ``` + + Returns: + ```json + { + "install_url": "https://slack.com/oauth/v2/authorize?state=..." + } + ``` + +2. **User clicks the URL** and authorizes the app in their Slack workspace. + +3. **Slack redirects** to `/integrations/slack/oauth/callback?code=...&state=...` + +4. The callback endpoint: + - Validates the state token + - Exchanges the code for access tokens via Slack API + - Saves the installation to the database + - Returns success message + +### Sending Messages + +Use the service method: + +```python +from core.integrations.slack.service import SlackService + +await slack_service.send_message_to_slack( + organisation_id=org.id, + channel="#general", # or channel ID like "C12345678" + text="Hello from the API!" +) +``` + +This will: +1. Look up the installation for the organisation +2. Use the stored `bot_token` to authenticate with Slack +3. Post the message to the specified channel + +### Getting Installation Info + +**Get all installations for an organisation:** + +```http +GET /api/integrations/slack/installations +Authorization: Bearer +``` + +Returns a list of all Slack workspace installations for the authenticated user's organisation: + +```json +[ + { + "id": "550e8400-e29b-41d4-a716-446655440000", + "organisation_id": "660e8400-e29b-41d4-a716-446655440000", + "team_id": "T12345678", + "team_name": "Marketing Workspace", + "bot_id": "B12345", + "incoming_webhook_channel": "#marketing", + "incoming_webhook_channel_id": "C12345678", + "created_at": "2026-03-18T23:14:16" + }, + { + "id": "770e8400-e29b-41d4-a716-446655440000", + "organisation_id": "660e8400-e29b-41d4-a716-446655440000", + "team_id": "T87654321", + "team_name": "Engineering Workspace", + "bot_id": "B87654", + "incoming_webhook_channel": "#engineering", + "incoming_webhook_channel_id": "C87654321", + "created_at": "2026-03-18T22:30:00" + } +] +``` + +**Empty list if no installations:** +```json +[] +``` + +**Programmatically:** + +```python +# Get all installations for an organisation +installations = await slack_service.get_installations_by_organisation(org.id) + +if installations: + # Use the first (most recent) installation + installation = installations[0] + print(f"Primary workspace: {installation.team_name} - Channel: {installation.incoming_webhook_channel}") + + # Or iterate through all installations + for installation in installations: + print(f"Workspace: {installation.team_name}") +else: + print("No Slack installations found for this organisation") +``` + +**Note:** An organisation can have multiple Slack installations, one for each Slack workspace they want to connect. Each installation is identified by the unique combination of `(organisation_id, team_id)`. The installations are returned ordered by creation date (most recent first). + +### Deleting an Installation + +**Remove a Slack installation:** + +```http +DELETE /api/integrations/slack/installations/{installation_id} +Authorization: Bearer +``` + +Returns: `204 No Content` on success + +This endpoint will: +1. **Revoke the bot token** via Slack API (`auth.revoke`), which: + - Deactivates the bot user + - Removes the bot from all channels + - Invalidates the incoming webhook +2. **Delete the installation record** from the database + +**Note:** After deletion, the bot will no longer have access to any channels and webhooks will stop working. Users will need to reinstall the app to restore functionality. + +**Programmatically:** + +```python +# Delete a specific installation +await slack_service.delete_installation( + organisation_id=org.id, + installation_id=installation_id +) +``` + +**Error cases:** +- `404 Not Found`: Installation doesn't exist +- `400 Bad Request`: Installation belongs to a different organisation +- `401 Unauthorized`: No authentication token provided + +**Graceful failure:** If the Slack API token revocation fails (e.g., token already invalid), the installation will still be deleted from the database. This ensures cleanup even if Slack communication fails. + +## Security Considerations + +- **OAuth states** expire after 5 minutes to prevent replay attacks. +- The `/install-url` endpoint requires authentication to prevent unauthorized installations. +- The `/oauth/callback` endpoint is public (called by Slack) but validates the state token. + +## Testing + +Run the test suite: + +```bash +python -m pytest tests/integrations/slack/ -v +``` + +Tests cover: +- OAuth state issuance and consumption +- State expiration +- Installation save/update/delete +- Bot lookup by team_id or organisation_id +- Message sending (with mocked Slack API) +- Error cases (invalid state, missing installation, etc.) + +## Migration from File-Based Storage + +If you have existing installations in `./data/`: + +1. Apply the database migration: `python -m core.migrate` +2. The new code will start using the database +3. Old file-based installations will be ignored +4. Users should re-install the app through the new OAuth flow + +## Database Migration + +The migration file `18.add-alert-notifications-on-slack-channels.up` creates both tables with proper indexes and foreign key constraints. + +Apply it with: +```bash +python -m core.migrate +``` + +## Scopes + +Current Slack app scopes: +- `incoming-webhook`: Post messages to channels +- `chat:write`: Send messages as @BOT_NAME +- `channels:join`: Allow bot to join to private channels +- `groups:write`: Allow bot to join to public channels + +To add more functionality (e.g., read messages, manage channels), update the scopes in `service.py`: + +```python +authorize_url_generator = AuthorizeUrlGenerator( + client_id=SLACK_CLIENT_ID, + scopes=["incoming-webhook", "chat:write", "channels:join", "groups:write"], + user_scopes=["channels:write", "groups:write"], # User scopes needed for inviting bot + redirect_uri=SLACK_REDIRECT_URI, +) +``` + +## Configuration + +Required environment variables (typically in `.env`): + +- `SLACK_CLIENT_ID`: Slack app client ID +- `SLACK_CLIENT_SECRET`: Slack app client secret +- `SLACK_REDIRECT_URI`: OAuth redirect URI (must match Slack app config) + +Example: +``` +SLACK_CLIENT_ID=123456789012.123456789012 +SLACK_CLIENT_SECRET=abcdef0123456789abcdef0123456789 +SLACK_REDIRECT_URI=https://api.example.com/integrations/slack/oauth/callback +``` diff --git a/core/integrations/slack/__init__.py b/core/integrations/slack/__init__.py new file mode 100644 index 0000000..ee4918e --- /dev/null +++ b/core/integrations/slack/__init__.py @@ -0,0 +1 @@ +"""Slack integration module.""" diff --git a/core/integrations/slack/controller.py b/core/integrations/slack/controller.py new file mode 100644 index 0000000..c7105dd --- /dev/null +++ b/core/integrations/slack/controller.py @@ -0,0 +1,133 @@ +from typing import Any +from uuid import UUID + +from litestar import Controller, Request, delete, get +from litestar.di import Provide +from litestar.params import Parameter +from litestar.status_codes import HTTP_204_NO_CONTENT + +from core.auth.models import AuthToken, Identity +from core.errors import OrganisationIDRequiredError +from core.integrations.slack.models import SlackInstallationResponse +from core.integrations.slack.service import SlackService +from core.response import JSON +from core.uow import ConnectionFactory + + +async def slack_service( + connection_factory: ConnectionFactory, +) -> SlackService: + return SlackService(connection_factory=connection_factory) + + +class SlackController(Controller): + path = "/slack" + tags = ["integrations", "slack"] + + dependencies = { + "slack_service": Provide(slack_service), + } + + @get( + path="/install-url", + summary="Get Slack installation link", + ) + async def get_install_url( + self, + request: Request[Identity, AuthToken, Any], + slack_service: SlackService, + ) -> JSON[dict[str, str]]: + """ + Generate a Slack installation URL for the authenticated user's organisation. + """ + if not request.user.organisation: + raise OrganisationIDRequiredError( + "User must be associated with an organisation" + ) + + url = await slack_service.generate_slack_auth_url( + organisation_id=request.user.organisation.id + ) + return JSON({"install_url": url}) + + @get( + path="/oauth/callback", + summary="Handle Slack OAuth callback", + exclude_from_auth=True, + ) + async def oauth_callback( + self, + slack_service: SlackService, + code: str, + oauth_state: str = Parameter(query="state"), + ) -> str: + """ + Handle Slack OAuth callback. This endpoint is called by Slack after the user + authorizes the app. The organisation_id is recovered from the state parameter. + """ + try: + await slack_service.process_oauth_callback(code=code, state=oauth_state) + return "Installation successful! You can close this window." + except ValueError as e: + return f"Installation failed: {str(e)}" + except Exception as e: + return f"An unexpected error occurred. Please, contact support." + + @get( + path="/installations", + summary="Get all Slack installations for the user's organisation", + ) + async def get_slack_installations( + self, + request: Request[Identity, AuthToken, Any], + slack_service: SlackService, + ) -> JSON[list[dict[str, Any]]]: + """ + Get all Slack installation details for the authenticated user's organisation. + An organisation can have multiple installations (one per Slack workspace). + Returns installation info without sensitive credentials (bot_token excluded). + """ + if not request.user.organisation: + raise OrganisationIDRequiredError( + "User must be associated with an organisation" + ) + + installations = await slack_service.get_installations_by_organisation( + request.user.organisation.id + ) + + # Convert all installations to response models, excluding sensitive fields + response = [ + SlackInstallationResponse.from_installation(installation).model_dump() + for installation in installations + ] + return JSON(response) + + @delete( + path="/installations/{installation_id:uuid}", + summary="Delete a Slack installation", + status_code=HTTP_204_NO_CONTENT, + ) + async def delete_slack_installation( + self, + request: Request[Identity, AuthToken, Any], + slack_service: SlackService, + installation_id: UUID, + ) -> None: + """ + Delete a Slack installation for the authenticated user's organisation. + This will: + 1. Revoke the bot token via Slack API (deactivates bot, removes channel memberships) + 2. Delete the installation record from the database + + The bot will lose access to all channels and the incoming webhook will stop working. + """ + if not request.user.organisation: + raise OrganisationIDRequiredError( + "User must be associated with an organisation" + ) + + await slack_service.delete_installation( + organisation_id=request.user.organisation.id, + installation_id=installation_id, + ) diff --git a/core/integrations/slack/models.py b/core/integrations/slack/models.py new file mode 100644 index 0000000..09d3239 --- /dev/null +++ b/core/integrations/slack/models.py @@ -0,0 +1,117 @@ +from datetime import datetime +from typing import Any +from uuid import UUID, uuid4 + +from pydantic import BaseModel, ConfigDict, Field + + +class SlackOAuthState(BaseModel): + """Temporary OAuth state for CSRF protection during Slack OAuth flow""" + + model_config = ConfigDict(str_strip_whitespace=True) + + id: UUID = Field(default_factory=uuid4) + state: str + organisation_id: UUID + created_at: datetime | None = None + expires_at: datetime + + +class SlackInstallation(BaseModel): + """Slack workspace installation containing bot credentials and webhook configuration""" + + model_config = ConfigDict(str_strip_whitespace=True) + + id: UUID = Field(default_factory=uuid4) + organisation_id: UUID + + # Slack workspace identification + team_id: str + team_name: str | None = None + enterprise_id: str | None = None + enterprise_name: str | None = None + enterprise_url: str | None = None + + # Slack app identification + app_id: str | None = None + + # Bot credentials and info + bot_token: str + bot_id: str | None = None + bot_user_id: str | None = None + bot_scopes: str | None = None + + # User who installed the app + user_id: str | None = None + user_token: str | None = None + user_scopes: str | None = None + + # Incoming webhook configuration + incoming_webhook_url: str | None = None + incoming_webhook_channel: str | None = None + incoming_webhook_channel_id: str | None = None + incoming_webhook_configuration_url: str | None = None + + # Installation metadata + is_enterprise_install: bool = False + token_type: str | None = None + metadata: dict[str, Any] = Field(default_factory=dict) + + # Timestamps + created_at: datetime | None = None + updated_at: datetime | None = None + + +class SlackInstallationResponse(BaseModel): + """Public-facing Slack installation info without sensitive credentials""" + + model_config = ConfigDict(str_strip_whitespace=True) + + id: UUID + organisation_id: UUID + + # Slack workspace identification + team_id: str + team_name: str | None = None + enterprise_id: str | None = None + enterprise_name: str | None = None + + # Slack app identification + app_id: str | None = None + + # Bot info (without token) + bot_id: str | None = None + bot_user_id: str | None = None + bot_scopes: str | None = None + + # Incoming webhook configuration + incoming_webhook_channel: str | None = None + incoming_webhook_channel_id: str | None = None + + # Installation metadata + is_enterprise_install: bool = False + + # Timestamps + created_at: datetime | None = None + updated_at: datetime | None = None + + @classmethod + def from_installation(cls, installation: "SlackInstallation") -> "SlackInstallationResponse": + """Convert a SlackInstallation to a public response, excluding sensitive fields""" + return cls( + id=installation.id, + organisation_id=installation.organisation_id, + team_id=installation.team_id, + team_name=installation.team_name, + enterprise_id=installation.enterprise_id, + enterprise_name=installation.enterprise_name, + app_id=installation.app_id, + bot_id=installation.bot_id, + bot_user_id=installation.bot_user_id, + bot_scopes=installation.bot_scopes, + incoming_webhook_channel=installation.incoming_webhook_channel, + incoming_webhook_channel_id=installation.incoming_webhook_channel_id, + is_enterprise_install=installation.is_enterprise_install, + created_at=installation.created_at, + updated_at=installation.updated_at, + ) diff --git a/core/integrations/slack/repo.py b/core/integrations/slack/repo.py new file mode 100644 index 0000000..fe790e9 --- /dev/null +++ b/core/integrations/slack/repo.py @@ -0,0 +1,324 @@ +import secrets +from datetime import datetime, timedelta +from uuid import UUID + +import psycopg +from psycopg.rows import DictRow +from psycopg.types.json import Jsonb + +from core.errors import NotFoundError +from core.integrations.slack.models import SlackInstallation, SlackOAuthState + + +class SlackRepository: + def __init__(self, session: psycopg.AsyncCursor[DictRow]) -> None: + self._session = session + + async def issue_state( + self, organisation_id: UUID, expiration_seconds: int = 300 + ) -> str: + """ + Generate and store a new OAuth state for CSRF protection. + + Args: + organisation_id: The organisation UUID to associate with this state + expiration_seconds: Number of seconds until the state expires (default: 300) + + Returns: + The generated state string + """ + state = secrets.token_urlsafe(32) + expires_at = datetime.now() + timedelta(seconds=expiration_seconds) + + await self._session.execute( + """ + INSERT INTO slack_oauth_states (state, organisation_id, expires_at) + VALUES (%(state)s, %(organisation_id)s, %(expires_at)s) + """, + { + "state": state, + "organisation_id": organisation_id, + "expires_at": expires_at, + }, + ) + + return state + + async def consume_state(self, state: str) -> UUID | None: + """ + Validate and consume an OAuth state. Returns the organisation_id if the state + is valid and not expired, None otherwise. Valid states are deleted after consumption. + + Args: + state: The state string to validate + + Returns: + The organisation UUID if the state is valid and not expired, None otherwise + """ + await self._session.execute( + """ + DELETE FROM slack_oauth_states + WHERE state = %(state)s AND expires_at > now() + RETURNING organisation_id + """, + {"state": state}, + ) + + row = await self._session.fetchone() + return row["organisation_id"] if row else None + + async def cleanup_expired_states(self) -> int: + """ + Delete expired OAuth states from the database. + + Returns: + Number of states deleted + """ + await self._session.execute( + """ + DELETE FROM slack_oauth_states + WHERE expires_at <= now() + """ + ) + # Note: rowcount is not available with AsyncCursor in all cases + # This is primarily for maintenance/cleanup + return 0 + + async def save_installation( + self, installation: SlackInstallation + ) -> SlackInstallation: + """ + Save or update a Slack installation for an organisation. + If an installation with the same organisation_id and incoming_webhook_channel_id exists, + it will be updated. This allows multiple installations per organisation (one per channel). + + Args: + installation: The installation to save + + Returns: + The saved installation with timestamps populated + """ + data = installation.model_dump() + # Convert metadata dict to JSONB + data["metadata"] = Jsonb(data.get("metadata", {})) + + await self._session.execute( + """ + INSERT INTO slack_installations ( + organisation_id, + team_id, + team_name, + enterprise_id, + enterprise_name, + enterprise_url, + app_id, + bot_token, + bot_id, + bot_user_id, + bot_scopes, + user_id, + user_token, + user_scopes, + incoming_webhook_url, + incoming_webhook_channel, + incoming_webhook_channel_id, + incoming_webhook_configuration_url, + is_enterprise_install, + token_type, + metadata + ) VALUES ( + %(organisation_id)s, + %(team_id)s, + %(team_name)s, + %(enterprise_id)s, + %(enterprise_name)s, + %(enterprise_url)s, + %(app_id)s, + %(bot_token)s, + %(bot_id)s, + %(bot_user_id)s, + %(bot_scopes)s, + %(user_id)s, + %(user_token)s, + %(user_scopes)s, + %(incoming_webhook_url)s, + %(incoming_webhook_channel)s, + %(incoming_webhook_channel_id)s, + %(incoming_webhook_configuration_url)s, + %(is_enterprise_install)s, + %(token_type)s, + %(metadata)s + ) + ON CONFLICT (organisation_id, incoming_webhook_channel_id) + DO UPDATE SET + team_id = EXCLUDED.team_id, + team_name = EXCLUDED.team_name, + enterprise_id = EXCLUDED.enterprise_id, + enterprise_name = EXCLUDED.enterprise_name, + enterprise_url = EXCLUDED.enterprise_url, + app_id = EXCLUDED.app_id, + bot_token = EXCLUDED.bot_token, + bot_id = EXCLUDED.bot_id, + bot_user_id = EXCLUDED.bot_user_id, + bot_scopes = EXCLUDED.bot_scopes, + user_id = EXCLUDED.user_id, + user_token = EXCLUDED.user_token, + user_scopes = EXCLUDED.user_scopes, + incoming_webhook_url = EXCLUDED.incoming_webhook_url, + incoming_webhook_channel = EXCLUDED.incoming_webhook_channel, + incoming_webhook_channel_id = EXCLUDED.incoming_webhook_channel_id, + incoming_webhook_configuration_url = EXCLUDED.incoming_webhook_configuration_url, + is_enterprise_install = EXCLUDED.is_enterprise_install, + token_type = EXCLUDED.token_type, + metadata = EXCLUDED.metadata, + updated_at = now() + RETURNING * + """, + data, + ) + + row = await self._session.fetchone() + if not row: + raise ValueError("could not save installation") + + return SlackInstallation(**row) + + async def find_bot( + self, enterprise_id: str | None = None, team_id: str | None = None + ) -> SlackInstallation | None: + """ + Find a Slack installation by enterprise_id and/or team_id. + This mirrors the slack_sdk InstallationStore.find_bot() interface. + + Args: + enterprise_id: Slack enterprise ID (optional) + team_id: Slack team/workspace ID (optional) + + Returns: + The installation if found, None otherwise + """ + if team_id: + # Search by team_id (and enterprise_id if provided) + query = """ + SELECT * FROM slack_installations + WHERE team_id = %(team_id)s + """ + params = {"team_id": team_id, "enterprise_id": enterprise_id} + + if enterprise_id: + query += " AND enterprise_id = %(enterprise_id)s" + elif enterprise_id: + # Search only by enterprise_id + query = """ + SELECT * FROM slack_installations + WHERE enterprise_id = %(enterprise_id)s + """ + params = {"enterprise_id": enterprise_id} + else: + # No search criteria provided + return None + + await self._session.execute(query, params) + row = await self._session.fetchone() + + if not row: + return None + + return SlackInstallation(**row) + + async def find_installations_by_organisation( + self, organisation_id: UUID + ) -> list[SlackInstallation]: + """ + Find all Slack installations for an organisation. + An organisation can have multiple installations (one per channel). + + Args: + organisation_id: The organisation UUID + + Returns: + List of installations (empty list if none found) + """ + await self._session.execute( + """ + SELECT * FROM slack_installations + WHERE organisation_id = %(organisation_id)s + ORDER BY created_at DESC + """, + {"organisation_id": organisation_id}, + ) + + rows = await self._session.fetchall() + return [SlackInstallation(**row) for row in rows] + + async def find_installation_by_channel_id( + self, channel_id: str + ) -> SlackInstallation | None: + """ + Find a Slack installation by channel ID. + + Args: + channel_id: The Slack channel ID (e.g., C0AMLPMBTGR) + + Returns: + The SlackInstallation if found, None otherwise + """ + await self._session.execute( + """ + SELECT * FROM slack_installations + WHERE incoming_webhook_channel_id = %(channel_id)s + LIMIT 1 + """, + {"channel_id": channel_id}, + ) + + row = await self._session.fetchone() + + if not row: + return None + + return SlackInstallation(**row) + + async def find_installation_by_id( + self, installation_id: UUID + ) -> SlackInstallation | None: + """ + Find a Slack installation by its ID. + + Args: + installation_id: The installation UUID + + Returns: + The installation if found, None otherwise + """ + await self._session.execute( + """ + SELECT * FROM slack_installations + WHERE id = %(installation_id)s + """, + {"installation_id": installation_id}, + ) + + row = await self._session.fetchone() + return SlackInstallation(**row) if row else None + + async def delete_installation( + self, organisation_id: UUID, team_id: str + ) -> None: + """ + Delete a Slack installation. + + Args: + organisation_id: The organisation UUID + team_id: The Slack team/workspace ID + """ + await self._session.execute( + """ + DELETE FROM slack_installations + WHERE organisation_id = %(organisation_id)s AND team_id = %(team_id)s + """, + {"organisation_id": organisation_id, "team_id": team_id}, + ) + + if self._session.rowcount == 0: + raise NotFoundError("installation not found") diff --git a/core/integrations/slack/service.py b/core/integrations/slack/service.py new file mode 100644 index 0000000..6d0ff2f --- /dev/null +++ b/core/integrations/slack/service.py @@ -0,0 +1,319 @@ +from typing import Any +from uuid import UUID + +from slack_sdk.errors import SlackApiError +from slack_sdk.oauth import AuthorizeUrlGenerator +from slack_sdk.web.async_client import AsyncWebClient +from slack_sdk.web import WebClient + +from core.config import SLACK_CLIENT_ID, SLACK_CLIENT_SECRET, SLACK_REDIRECT_URI +from core.integrations.slack.models import SlackInstallation +from core.integrations.slack.repo import SlackRepository +from core.uow import ConnectionFactory, uow + + +# Build https://slack.com/oauth/v2/authorize with sufficient query parameters +authorize_url_generator = AuthorizeUrlGenerator( + client_id=SLACK_CLIENT_ID, + scopes=["incoming-webhook", "chat:write", "channels:join", "groups:write"], + user_scopes=["channels:write", "groups:write"], # User scopes needed for inviting bot + redirect_uri=SLACK_REDIRECT_URI, +) + + +class SlackService: + def __init__(self, connection_factory: ConnectionFactory): + self.connection_factory = connection_factory + + async def generate_slack_auth_url(self, organisation_id: UUID) -> str: + """ + Generate Slack installation URL with a temporary OAuth state. + + Args: + organisation_id: The organisation UUID to associate with this installation + + Returns: + The authorization URL for the user to visit + """ + # Generate a random value and store it in the database + async with uow(SlackRepository, self.connection_factory) as repo: + state = await repo.issue_state( + organisation_id=organisation_id, expiration_seconds=300 + ) + + # https://slack.com/oauth/v2/authorize?state=(generated value)&client_id={client_id}&scope=incoming-webhook%2Cchat:write + url = authorize_url_generator.generate(state) + return url + + async def process_oauth_callback(self, code: str, state: str) -> SlackInstallation: + """ + Process Slack OAuth callback and save the installation. + Also ensures the bot has access to the incoming webhook channel. + + Args: + code: OAuth authorization code from Slack + state: OAuth state parameter for CSRF protection + + Returns: + The saved SlackInstallation + + Raises: + ValueError: If the state is invalid or expired + """ + # Validate and consume the state, getting the organisation_id back + async with uow(SlackRepository, self.connection_factory) as repo: + organisation_id = await repo.consume_state(state) + + if organisation_id is None: + raise ValueError("Invalid or expired state parameter") + + client = WebClient() # no prepared token needed for this + # Complete the installation by calling oauth.v2.access API method + oauth_response = client.oauth_v2_access( + client_id=SLACK_CLIENT_ID, + client_secret=SLACK_CLIENT_SECRET, + redirect_uri=SLACK_REDIRECT_URI, + code=code, + ) + + installed_enterprise = oauth_response.get("enterprise") or {} + is_enterprise_install = oauth_response.get("is_enterprise_install") + installed_team = oauth_response.get("team") or {} + installer = oauth_response.get("authed_user") or {} + incoming_webhook = oauth_response.get("incoming_webhook") or {} + bot_token = oauth_response.get("access_token") + user_token = installer.get("access_token") + + # NOTE: oauth.v2.access doesn't include bot_id in response + bot_id = None + enterprise_url = None + if bot_token is not None: + auth_test = client.auth_test(token=bot_token) + bot_id = auth_test["bot_id"] + if is_enterprise_install is True: + enterprise_url = auth_test.get("url") + + # Ensure bot has access to the incoming webhook channel + incoming_webhook_channel_id = incoming_webhook.get("channel_id") + bot_user_id = oauth_response.get("bot_user_id") + + if bot_token and incoming_webhook_channel_id and bot_user_id: + await self._ensure_bot_in_channel( + bot_token=bot_token, + user_token=user_token, + channel_id=incoming_webhook_channel_id, + bot_user_id=bot_user_id, + ) + + # Validate required fields from Slack OAuth response + team_id = installed_team.get("id") + if not isinstance(team_id, str): + raise ValueError("Missing required field: team_id") + if not isinstance(bot_token, str): + raise ValueError("Missing required field: bot_token") + + # Create our SlackInstallation model + installation = SlackInstallation( + organisation_id=organisation_id, + app_id=oauth_response.get("app_id"), + enterprise_id=installed_enterprise.get("id"), + enterprise_name=installed_enterprise.get("name"), + enterprise_url=enterprise_url, + team_id=team_id, + team_name=installed_team.get("name"), + bot_token=bot_token, + bot_id=bot_id, + bot_user_id=bot_user_id, + bot_scopes=oauth_response.get("scope"), # comma-separated string + user_id=installer.get("id"), + user_token=user_token, + user_scopes=installer.get("scope"), # comma-separated string + incoming_webhook_url=incoming_webhook.get("url"), + incoming_webhook_channel=incoming_webhook.get("channel"), + incoming_webhook_channel_id=incoming_webhook_channel_id, + incoming_webhook_configuration_url=incoming_webhook.get( + "configuration_url" + ), + is_enterprise_install=is_enterprise_install or False, + token_type=oauth_response.get("token_type"), + ) + + # Store the installation in the database + async with uow(SlackRepository, self.connection_factory) as repo: + saved_installation = await repo.save_installation(installation) + + return saved_installation + + async def _ensure_bot_in_channel( + self, + bot_token: str, + user_token: str | None, + channel_id: str, + bot_user_id: str, + ) -> None: + """ + Ensure the bot has access to a Slack channel during installation. + Tries to join (public channels) or be invited (private channels). + + Args: + bot_token: The bot token for API calls + user_token: The user token for inviting the bot (optional) + channel_id: The Slack channel ID + bot_user_id: The bot's user ID for invitation + + Raises: + Exception: If bot cannot access the channel + """ + bot_client = AsyncWebClient(token=bot_token) + + # Check if bot is already a member + try: + info_response = await bot_client.conversations_info(channel=channel_id) + if info_response["ok"] and info_response["channel"].get("is_member"): + return # Already a member, nothing to do + except Exception: + pass # Continue to join/invite + + # Strategy 1: Try bot self-join (works for public channels) + try: + join_response = await bot_client.conversations_join(channel=channel_id) + if join_response["ok"]: + return # Successfully joined + except SlackApiError as join_error: + # Strategy 2: If self-join failed (likely private channel), try to invite bot + if join_error.response.get("error") == "channel_not_found": + if not user_token: + # Can't invite to private channel without user token, but that's okay + # User can manually invite later if needed + return + + try: + # Use user token to invite the bot to the private channel + user_client = AsyncWebClient(token=user_token) + await user_client.conversations_invite( + channel=channel_id, + users=bot_user_id + ) + return # Successfully invited + except Exception: + # If invitation fails, it's okay - user can invite manually later + pass + + async def send_message_to_slack( + self, organisation_id: UUID, channel: str, text: str + ) -> None: + """ + Send a message to a Slack channel using the stored bot token. + Looks up the installation directly by channel ID for efficient workspace resolution. + + Args: + organisation_id: The organisation UUID (for validation) + channel: The Slack channel ID to post to + text: The message text to send + + Raises: + ValueError: If no installation is found for the channel or organisation mismatch + Exception: If message sending fails + """ + # Find the installation for this specific channel + async with uow(SlackRepository, self.connection_factory) as repo: + installation = await repo.find_installation_by_channel_id(channel) + + if not installation: + raise ValueError( + f"No Slack installation found for channel {channel}. " + f"Make sure the Slack integration has been installed for this channel's workspace." + ) + + # Verify the installation belongs to the correct organisation + if installation.organisation_id != organisation_id: + raise ValueError( + f"Channel {channel} belongs to a different organisation" + ) + + # Send the message using the installation's bot token + bot_client = AsyncWebClient(token=installation.bot_token) + + try: + response = await bot_client.chat_postMessage(channel=channel, text=text) + + if not response["ok"]: + error = response.get('error', 'Unknown error') + raise Exception(f"Slack API error: {error}") + + except Exception as e: + raise Exception( + f"Failed to send Slack message to channel '{channel}': {str(e)}. " + f"Make sure the bot has been invited to this channel." + ) + + async def get_installations_by_organisation( + self, organisation_id: UUID + ) -> list[SlackInstallation]: + """ + Get all Slack installations for an organisation. + An organisation can have multiple installations (one per Slack workspace). + + Args: + organisation_id: The organisation UUID + + Returns: + List of SlackInstallations (empty list if none found) + """ + async with uow(SlackRepository, self.connection_factory) as repo: + return await repo.find_installations_by_organisation(organisation_id) + + async def delete_installation( + self, organisation_id: UUID, installation_id: UUID + ) -> None: + """ + Delete a Slack installation and revoke the bot token. + This will: + 1. Revoke the bot token via Slack API (deactivates bot, removes channel memberships) + 2. Delete the installation record from the database + + Args: + organisation_id: The organisation UUID (for authorization check) + installation_id: The installation UUID to delete + + Raises: + NotFoundError: If installation not found + ValueError: If installation belongs to a different organisation + Exception: If token revocation fails (installation will still be deleted) + """ + # Find the installation first (to get bot_token and verify ownership) + async with uow(SlackRepository, self.connection_factory) as repo: + installation = await repo.find_installation_by_id(installation_id) + + if not installation: + from core.errors import NotFoundError + raise NotFoundError("Installation not found") + + # Verify the installation belongs to the correct organisation + if installation.organisation_id != organisation_id: + raise ValueError( + "Installation belongs to a different organisation" + ) + + # Revoke the bot token via Slack API + # This deactivates the bot and removes its channel memberships + if installation.bot_token: + try: + bot_client = AsyncWebClient(token=installation.bot_token) + response = await bot_client.auth_revoke() + + if not response.get("ok"): + # Log the error but continue with deletion + error = response.get('error', 'Unknown error') + print(f"Warning: Failed to revoke Slack token: {error}") + except Exception as e: + # Log the error but continue with deletion + # The token might already be invalid or revoked + print(f"Warning: Error revoking Slack token: {str(e)}") + + # Delete the installation from the database + async with uow(SlackRepository, self.connection_factory) as repo: + await repo.delete_installation( + organisation_id=organisation_id, + team_id=installation.team_id + ) diff --git a/core/migrations/18.add-alert-notifications-on-slack-channels.up.sql b/core/migrations/18.add-alert-notifications-on-slack-channels.up.sql new file mode 100644 index 0000000..2c2d97e --- /dev/null +++ b/core/migrations/18.add-alert-notifications-on-slack-channels.up.sql @@ -0,0 +1,135 @@ +BEGIN; + +-- Table for Slack OAuth temporary states (for CSRF protection during OAuth flow) +CREATE TABLE IF NOT EXISTS slack_oauth_states ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + state TEXT NOT NULL UNIQUE, + organisation_id uuid NOT NULL REFERENCES organisations(id) ON DELETE CASCADE, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL +); + +-- Index for efficient state lookup and cleanup +CREATE INDEX idx_slack_oauth_states_state ON slack_oauth_states(state); +CREATE INDEX idx_slack_oauth_states_expires_at ON slack_oauth_states(expires_at); + +-- Table for Slack workspace installations +CREATE TABLE IF NOT EXISTS slack_installations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + organisation_id uuid NOT NULL REFERENCES organisations(id) ON DELETE CASCADE, + + -- Slack workspace identification + team_id TEXT NOT NULL, + team_name TEXT, + enterprise_id TEXT, + enterprise_name TEXT, + enterprise_url TEXT, + + -- Slack app identification + app_id TEXT, + + -- Bot credentials and info + bot_token TEXT NOT NULL, + bot_id TEXT, + bot_user_id TEXT, + bot_scopes TEXT, + + -- User who installed the app + user_id TEXT, + user_token TEXT, + user_scopes TEXT, + + -- Incoming webhook configuration + incoming_webhook_url TEXT, + incoming_webhook_channel TEXT, + incoming_webhook_channel_id TEXT, + incoming_webhook_configuration_url TEXT, + + -- Installation metadata + is_enterprise_install BOOLEAN DEFAULT FALSE, + token_type TEXT, + metadata JSONB DEFAULT '{}', + + -- Timestamps + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + + -- Ensure one installation per organisation per channel + UNIQUE(organisation_id, incoming_webhook_channel_id) +); + +-- Indexes for efficient lookups +CREATE INDEX idx_slack_installations_organisation_id ON slack_installations(organisation_id); +CREATE INDEX idx_slack_installations_enterprise_id ON slack_installations(enterprise_id) WHERE enterprise_id IS NOT NULL; +CREATE INDEX idx_slack_installations_incoming_webhook_channel_id ON slack_installations(incoming_webhook_channel_id) WHERE incoming_webhook_channel_id IS NOT NULL; + +-- Add multi-channel notification tracking to alerts_triggered +ALTER TABLE alerts_triggered +ADD COLUMN notification_status JSONB DEFAULT '{}'::jsonb; + +-- Migrate existing notification_sent data to new format +UPDATE alerts_triggered +SET notification_status = + CASE + WHEN notification_sent = TRUE THEN '{"email": "sent"}'::jsonb + ELSE '{}'::jsonb + END; + +-- Create GIN index for efficient JSONB queries +CREATE INDEX idx_alerts_triggered_notification_status +ON alerts_triggered USING gin(notification_status); + +-- Note: notification_sent column kept for backward compatibility (deprecated) + +-- Add channels column to alerts table for multi-channel notifications +ALTER TABLE alerts +ADD COLUMN channels JSONB DEFAULT '[{"channel_type": "email"}]'::jsonb NOT NULL; + +-- Create GIN index for efficient JSONB queries on channels +CREATE INDEX idx_alerts_channels +ON alerts USING gin(channels); + +-- Ensure all existing alerts have at least the email channel +-- This handles alerts that had no channels in metadata or had empty arrays +UPDATE alerts +SET channels = '[{"channel_type": "email"}]'::jsonb +WHERE channels = '[]'::jsonb OR channels IS NULL; + +-- Add constraint to ensure channels array is never empty +ALTER TABLE alerts +ADD CONSTRAINT alerts_channels_not_empty +CHECK (jsonb_array_length(channels) > 0); + + +-- Rename the column (Rename emails_sent to notifications_sent in alert_executions table) +-- This migration makes the notification counting more generic to support multiple channels +ALTER TABLE alert_executions +RENAME COLUMN emails_sent TO notifications_sent; + +-- Update existing rows to include detailed breakdown in metadata +-- This preserves the email count in the new notifications_sent field +-- and adds it to metadata for backward compatibility +UPDATE alert_executions +SET metadata = jsonb_set( + COALESCE(metadata, '{}'::jsonb), + '{emails_sent}', + to_jsonb(notifications_sent) +) +WHERE metadata IS NULL + OR NOT metadata ? 'emails_sent'; + +-- Add default for slack_messages_sent if not present +UPDATE alert_executions +SET metadata = jsonb_set( + COALESCE(metadata, '{}'::jsonb), + '{slack_messages_sent}', + '0'::jsonb +) +WHERE metadata IS NULL + OR NOT metadata ? 'slack_messages_sent'; + +-- Add comment to the column +COMMENT ON COLUMN alert_executions.notifications_sent IS 'Total number of notifications sent across all channels (email + Slack + etc). Detailed breakdown available in metadata.'; + + +COMMIT; diff --git a/pyproject.toml b/pyproject.toml index 6310aff..3f3feda 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,8 @@ dependencies = [ "bcrypt>=4.3.0", "python-i18n>=0.3.9", "langid>=1.1.6", + "slack-sdk>=3.41.0", + "aiohttp>=3.13.3", ] [project.scripts] diff --git a/tests/alerts/test_multichannel.py b/tests/alerts/test_multichannel.py new file mode 100644 index 0000000..5260b6b --- /dev/null +++ b/tests/alerts/test_multichannel.py @@ -0,0 +1,193 @@ +"""Tests for multi-channel alert notifications (email + Slack)""" +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +import pytest + +from core.alerts.models import Alert, AlertScope, AlertType, AlertTriggered, ChannelConfig, ChannelType +from core.alerts.repo import AlertRepository +from core.alerts.service import AlertService + + +@pytest.fixture +def alert_with_email_only(): + """Alert configured for email only""" + return Alert( + id=uuid4(), + user_id=uuid4(), + organisation_id=uuid4(), + name="Test Email Alert", + alert_type=AlertType.NARRATIVE_VIEWS, + scope=AlertScope.GENERAL, + threshold=1000, + enabled=True, + channels=[{"channel_type": "email"}] + ) + + +@pytest.fixture +def alert_with_slack_only(): + """Alert configured for Slack only""" + return Alert( + id=uuid4(), + user_id=uuid4(), + organisation_id=uuid4(), + name="Test Slack Alert", + alert_type=AlertType.NARRATIVE_VIEWS, + scope=AlertScope.GENERAL, + threshold=1000, + enabled=True, + channels=[{"channel_type": "slack", "slack_channel_id": "C12345678"}] + ) + + +@pytest.fixture +def alert_with_both_channels(): + """Alert configured for both email and Slack""" + return Alert( + id=uuid4(), + user_id=uuid4(), + organisation_id=uuid4(), + name="Test Multi-Channel Alert", + alert_type=AlertType.NARRATIVE_VIEWS, + scope=AlertScope.GENERAL, + threshold=1000, + enabled=True, + channels=[ + {"channel_type": "email"}, + {"channel_type": "slack", "slack_channel_id": "C12345678"} + ] + ) + + +class TestChannelConfig: + """Test ChannelConfig model validation""" + + def test_email_channel_config_valid(self): + """Email channel config should be valid without slack_channel_id""" + config = ChannelConfig(channel_type=ChannelType.EMAIL) + assert config.channel_type == ChannelType.EMAIL + assert config.slack_channel_id is None + + def test_slack_channel_config_valid(self): + """Slack channel config should be valid with slack_channel_id""" + config = ChannelConfig( + channel_type=ChannelType.SLACK, + slack_channel_id="C12345678" + ) + assert config.channel_type == ChannelType.SLACK + assert config.slack_channel_id == "C12345678" + + def test_slack_channel_config_missing_id_invalid(self): + """Slack channel config should fail validation without slack_channel_id""" + with pytest.raises(ValueError, match="slack_channel_id is required"): + ChannelConfig(channel_type=ChannelType.SLACK) + + def test_email_channel_config_with_slack_id_invalid(self): + """Email channel config should fail validation with slack_channel_id""" + with pytest.raises(ValueError, match="should not be provided"): + ChannelConfig( + channel_type=ChannelType.EMAIL, + slack_channel_id="C12345678" + ) + + +class TestAlertHelperProperties: + """Test Alert model helper properties for channel configs""" + + def test_has_email_channel(self, alert_with_email_only): + """Alert should correctly identify email channel""" + assert alert_with_email_only.has_email_channel is True + assert alert_with_email_only.has_slack_channel is False + + def test_has_slack_channel(self, alert_with_slack_only): + """Alert should correctly identify Slack channel""" + assert alert_with_slack_only.has_email_channel is False + assert alert_with_slack_only.has_slack_channel is True + + def test_has_both_channels(self, alert_with_both_channels): + """Alert should correctly identify both channels""" + assert alert_with_both_channels.has_email_channel is True + assert alert_with_both_channels.has_slack_channel is True + + def test_slack_channel_ids_extraction(self, alert_with_slack_only): + """Alert should extract slack_channel_ids correctly""" + channel_ids = alert_with_slack_only.slack_channel_ids + assert channel_ids == ["C12345678"] + + def test_slack_channel_ids_empty_for_email(self, alert_with_email_only): + """Alert without Slack should return empty channel_ids list""" + channel_ids = alert_with_email_only.slack_channel_ids + assert channel_ids == [] + + def test_channel_configs_default_to_email(self): + """Alert without channels metadata should default to email""" + alert = Alert( + id=uuid4(), + user_id=uuid4(), + organisation_id=uuid4(), + name="Test", + alert_type=AlertType.NARRATIVE_VIEWS, + scope=AlertScope.GENERAL, + threshold=1000, + enabled=True, + metadata={} + ) + assert alert.channel_configs == [{"channel_type": "email"}] + assert alert.has_email_channel is True + + +class TestAlertRepositoryChannelDelivery: + """Test AlertRepository.record_channel_delivery method""" + + @pytest.mark.asyncio + async def test_record_email_delivery_success(self): + """Should record email delivery status in JSONB field""" + mock_cursor = AsyncMock() + mock_cursor.rowcount = 1 + + repo = AlertRepository(mock_cursor) + triggered_id = uuid4() + + result = await repo.record_channel_delivery(triggered_id, "email", "sent") + + assert result is True + mock_cursor.execute.assert_called_once() + + # Verify the SQL includes JSONB update + call_args = mock_cursor.execute.call_args + sql = call_args[0][0] + assert "notification_status || " in sql.lower() + + @pytest.mark.asyncio + async def test_record_slack_delivery_failed(self): + """Should record Slack delivery failure in JSONB field""" + mock_cursor = AsyncMock() + mock_cursor.rowcount = 1 + + repo = AlertRepository(mock_cursor) + triggered_id = uuid4() + + result = await repo.record_channel_delivery(triggered_id, "slack", "failed") + + assert result is True + mock_cursor.execute.assert_called_once() + + +class TestMultiChannelNotifications: + """Test multi-channel notification flow in AlertService""" + + @pytest.mark.asyncio + async def test_sends_to_both_email_and_slack(self, alert_with_both_channels): + """Should send notifications to both email and Slack channels""" + # This is a placeholder for integration test + # Full implementation would require mocking email and Slack services + assert alert_with_both_channels.has_email_channel + assert alert_with_both_channels.has_slack_channel + + @pytest.mark.asyncio + async def test_slack_failure_does_not_block_email(self): + """Slack delivery failure should not prevent email delivery""" + # This is a placeholder for integration test + # Would test that email is sent even if Slack fails + pass diff --git a/tests/integrations/__init__.py b/tests/integrations/__init__.py new file mode 100644 index 0000000..e424f69 --- /dev/null +++ b/tests/integrations/__init__.py @@ -0,0 +1 @@ +# Tests for Slack integration diff --git a/tests/integrations/slack/__init__.py b/tests/integrations/slack/__init__.py new file mode 100644 index 0000000..6309dac --- /dev/null +++ b/tests/integrations/slack/__init__.py @@ -0,0 +1 @@ +# Tests for Slack OAuth and messaging diff --git a/tests/integrations/slack/conftest.py b/tests/integrations/slack/conftest.py new file mode 100644 index 0000000..4d594b0 --- /dev/null +++ b/tests/integrations/slack/conftest.py @@ -0,0 +1,85 @@ +from uuid import uuid4 + +from polyfactory.factories.pydantic_factory import ModelFactory +from polyfactory.field_meta import FieldMeta +from pytest import fixture + +from core.auth.models import Organisation +from core.auth.service import AuthService +from core.integrations.slack.models import SlackInstallation +from core.integrations.slack.repo import SlackRepository +from core.integrations.slack.service import SlackService +from core.uow import uow +from tests.auth.conftest import create_organisation + + +@fixture +def tables_to_truncate() -> list[str]: + return [ + "slack_installations", + "slack_oauth_states", + "organisations", + ] + + +class SlackInstallationFactory(ModelFactory[SlackInstallation]): + __check_model__ = True + + @classmethod + def should_set_none_value(cls, field_meta: FieldMeta) -> bool: + # Set optional fields to None by default for cleaner test data + optional_fields = { + "team_name", + "enterprise_id", + "enterprise_name", + "enterprise_url", + "app_id", + "bot_id", + "bot_user_id", + "bot_scopes", + "user_id", + "user_token", + "user_scopes", + "incoming_webhook_url", + "incoming_webhook_channel", + "incoming_webhook_channel_id", + "incoming_webhook_configuration_url", + "token_type", + "created_at", + "updated_at", + } + if field_meta.name in optional_fields: + return True + + return super().should_set_none_value(field_meta) + + +@fixture +async def slack_service(conn_factory) -> SlackService: + return SlackService(conn_factory) + + +@fixture +async def slack_organisation(conn_factory) -> Organisation: + auth_service = AuthService(conn_factory) + return await create_organisation(auth_service) + + +@fixture +async def slack_installation( + conn_factory, slack_organisation: Organisation +) -> SlackInstallation: + """Create a test Slack installation""" + installation = SlackInstallationFactory.build( + organisation_id=slack_organisation.id, + team_id=f"T{uuid4().hex[:10].upper()}", + team_name="Test Workspace", + bot_token="xoxb-test-token", + incoming_webhook_url="https://hooks.slack.com/services/TEST/WEBHOOK/URL", + incoming_webhook_channel="#general", + incoming_webhook_channel_id=f"C{uuid4().hex[:10].upper()}", + ) + + # Save using the repository through UoW pattern + async with uow(SlackRepository, conn_factory) as repo: + return await repo.save_installation(installation) diff --git a/tests/integrations/slack/test_service.py b/tests/integrations/slack/test_service.py new file mode 100644 index 0000000..231409c --- /dev/null +++ b/tests/integrations/slack/test_service.py @@ -0,0 +1,483 @@ +from unittest.mock import AsyncMock, MagicMock, patch +from uuid import uuid4 + +from pytest import raises + +from core.auth.models import Organisation +from core.integrations.slack.models import SlackInstallation, SlackInstallationResponse +from core.integrations.slack.repo import SlackRepository +from core.integrations.slack.service import SlackService +from core.uow import uow +from tests.integrations.slack.conftest import SlackInstallationFactory + + +async def test_issue_state(conn_factory, slack_organisation: Organisation) -> None: + """Test that we can issue an OAuth state""" + async with uow(SlackRepository, conn_factory) as repo: + state = await repo.issue_state( + slack_organisation.id, expiration_seconds=300 + ) + assert state + assert len(state) > 20 # Should be a long random string + + +async def test_consume_valid_state(conn_factory, slack_organisation: Organisation) -> None: + """Test that we can consume a valid state and get the organisation_id back""" + async with uow(SlackRepository, conn_factory) as repo: + state = await repo.issue_state( + slack_organisation.id, expiration_seconds=300 + ) + + async with uow(SlackRepository, conn_factory) as repo: + organisation_id = await repo.consume_state(state) + assert organisation_id == slack_organisation.id + + # State should be consumed (deleted) after use + async with uow(SlackRepository, conn_factory) as repo: + organisation_id_again = await repo.consume_state(state) + assert organisation_id_again is None + + +async def test_consume_expired_state( + conn_factory, slack_organisation: Organisation +) -> None: + """Test that expired states cannot be consumed""" + # Create a state that's already expired + async with uow(SlackRepository, conn_factory) as repo: + state = await repo.issue_state( + slack_organisation.id, expiration_seconds=-10 + ) # Negative = already expired + + async with uow(SlackRepository, conn_factory) as repo: + organisation_id = await repo.consume_state(state) + assert organisation_id is None + + +async def test_consume_invalid_state(conn_factory) -> None: + """Test that invalid states cannot be consumed""" + async with uow(SlackRepository, conn_factory) as repo: + organisation_id = await repo.consume_state("invalid-state-12345") + assert organisation_id is None + + +async def test_save_installation( + conn_factory, slack_organisation: Organisation +) -> None: + """Test saving a new installation""" + installation = SlackInstallationFactory.build( + organisation_id=slack_organisation.id, + team_id="T12345678", + team_name="Test Workspace", + bot_token="xoxb-test-token", + ) + + async with uow(SlackRepository, conn_factory) as repo: + saved = await repo.save_installation(installation) + + assert saved.id is not None + assert saved.organisation_id == slack_organisation.id + assert saved.team_id == "T12345678" + assert saved.bot_token == "xoxb-test-token" + assert saved.created_at is not None + + +async def test_update_existing_installation( + conn_factory, slack_installation: SlackInstallation +) -> None: + """Test updating an existing installation (same org + channel)""" + original_id = slack_installation.id + + # Create an "updated" installation with same org and channel_id + # This simulates reinstalling the app on the same channel + updated = SlackInstallationFactory.build( + organisation_id=slack_installation.organisation_id, + incoming_webhook_channel_id=slack_installation.incoming_webhook_channel_id, + team_id=slack_installation.team_id, # Could be same or different workspace + team_name="Updated Workspace Name", + bot_token="xoxb-new-token", + ) + + async with uow(SlackRepository, conn_factory) as repo: + saved = await repo.save_installation(updated) + + # Should have same ID (updated, not inserted) + assert saved.id == original_id + assert saved.team_name == "Updated Workspace Name" + assert saved.bot_token == "xoxb-new-token" + + +async def test_multiple_installations_per_organisation( + conn_factory, slack_organisation: Organisation +) -> None: + """Test that one organisation can have multiple installations (one per channel)""" + # Create first installation for channel 1 + installation1 = SlackInstallationFactory.build( + organisation_id=slack_organisation.id, + team_id="T11111111", + bot_token="xoxb-token-1", + incoming_webhook_channel_id="C11111111", + ) + + # Create second installation for channel 2 (same or different workspace) + installation2 = SlackInstallationFactory.build( + organisation_id=slack_organisation.id, + team_id="T22222222", + bot_token="xoxb-token-2", + incoming_webhook_channel_id="C22222222", + ) + + async with uow(SlackRepository, conn_factory) as repo: + saved1 = await repo.save_installation(installation1) + saved2 = await repo.save_installation(installation2) + + # Both should be saved with different IDs + assert saved1.id != saved2.id + assert saved1.incoming_webhook_channel_id == "C11111111" + assert saved2.incoming_webhook_channel_id == "C22222222" + + +async def test_find_bot_by_team_id( + conn_factory, slack_installation: SlackInstallation +) -> None: + """Test finding an installation by team_id""" + async with uow(SlackRepository, conn_factory) as repo: + found = await repo.find_bot(team_id=slack_installation.team_id) + + assert found is not None + assert found.id == slack_installation.id + assert found.team_id == slack_installation.team_id + + +async def test_find_bot_not_found(conn_factory) -> None: + """Test that finding a nonexistent installation returns None""" + async with uow(SlackRepository, conn_factory) as repo: + found = await repo.find_bot(team_id="T99999999") + assert found is None + + +async def test_find_installation_by_organisation( + conn_factory, slack_installation: SlackInstallation +) -> None: + """Test finding installations by organisation_id""" + async with uow(SlackRepository, conn_factory) as repo: + found = await repo.find_installations_by_organisation( + slack_installation.organisation_id + ) + + assert found is not None + assert len(found) == 1 + assert found[0].id == slack_installation.id + assert found[0].organisation_id == slack_installation.organisation_id + + +async def test_find_installation_by_organisation_not_found(conn_factory) -> None: + """Test that finding by nonexistent organisation returns empty list""" + async with uow(SlackRepository, conn_factory) as repo: + found = await repo.find_installations_by_organisation(uuid4()) + assert found == [] + + +async def test_delete_installation( + conn_factory, slack_installation: SlackInstallation +) -> None: + """Test deleting an installation""" + async with uow(SlackRepository, conn_factory) as repo: + await repo.delete_installation( + slack_installation.organisation_id, slack_installation.team_id + ) + + # Should not be found after deletion + async with uow(SlackRepository, conn_factory) as repo: + found = await repo.find_installations_by_organisation( + slack_installation.organisation_id + ) + assert found == [] + + +async def test_generate_slack_auth_url( + slack_service: SlackService, slack_organisation: Organisation +) -> None: + """Test generating a Slack OAuth URL""" + url = await slack_service.generate_slack_auth_url(slack_organisation.id) + + assert url + assert "slack.com/oauth/v2/authorize" in url + assert "state=" in url + assert "client_id=" in url + assert "scope=" in url + + +async def test_process_oauth_callback( + slack_service: SlackService, slack_organisation: Organisation +) -> None: + """Test processing an OAuth callback with mocked Slack API""" + # Generate a valid state + url = await slack_service.generate_slack_auth_url(slack_organisation.id) + # Extract state from URL + state = url.split("state=")[1].split("&")[0] + + # Mock the Slack WebClient responses + mock_oauth_response = { + "ok": True, + "access_token": "xoxb-mock-token", + "app_id": "A12345", + "team": {"id": "T12345", "name": "Test Team"}, + "enterprise": {}, + "authed_user": {"id": "U12345", "access_token": "xoxp-user-token"}, + "incoming_webhook": { + "url": "https://hooks.slack.com/services/TEST", + "channel": "#general", + "channel_id": "C12345", + "configuration_url": "https://test.slack.com/config", + }, + "scope": "incoming-webhook", + "token_type": "bot", + "bot_user_id": "U12345BOT", + } + + mock_auth_test_response = { + "ok": True, + "bot_id": "B12345", + } + + with patch("core.integrations.slack.service.WebClient") as MockWebClient, \ + patch("core.integrations.slack.service.AsyncWebClient") as MockAsyncWebClient: + mock_client = MagicMock() + mock_client.oauth_v2_access.return_value = mock_oauth_response + mock_client.auth_test.return_value = mock_auth_test_response + MockWebClient.return_value = mock_client + + # Mock async client for _ensure_bot_in_channel + mock_async_client = MagicMock() + mock_async_client.conversations_join = AsyncMock(return_value={"ok": True}) + MockAsyncWebClient.return_value = mock_async_client + + installation = await slack_service.process_oauth_callback( + code="mock-code", state=state + ) + + assert installation is not None + assert installation.organisation_id == slack_organisation.id + assert installation.bot_token == "xoxb-mock-token" + assert installation.team_id == "T12345" + assert installation.team_name == "Test Team" + assert installation.bot_id == "B12345" + + +async def test_process_oauth_callback_invalid_state( + slack_service: SlackService, +) -> None: + """Test that invalid state raises an error""" + with raises(ValueError, match="Invalid or expired state"): + await slack_service.process_oauth_callback( + code="mock-code", state="invalid-state" + ) + + +async def test_send_message_to_slack( + slack_service: SlackService, slack_installation: SlackInstallation +) -> None: + """Test sending a message to Slack""" + with patch("core.integrations.slack.service.AsyncWebClient") as MockAsyncWebClient: + mock_client = MagicMock() + mock_response = {"ok": True} + mock_client.chat_postMessage = AsyncMock(return_value=mock_response) + MockAsyncWebClient.return_value = mock_client + + await slack_service.send_message_to_slack( + organisation_id=slack_installation.organisation_id, + channel=slack_installation.incoming_webhook_channel_id, + text="Test message", + ) + + # Verify AsyncWebClient was called with correct token + MockAsyncWebClient.assert_called_once_with(token=slack_installation.bot_token) + + # Verify chat_postMessage was called + mock_client.chat_postMessage.assert_called_once_with( + channel=slack_installation.incoming_webhook_channel_id, text="Test message" + ) + + +async def test_send_message_no_installation( + slack_service: SlackService, slack_organisation: Organisation +) -> None: + """Test that sending a message without an installation raises an error""" + with raises(ValueError, match="No Slack installation found"): + await slack_service.send_message_to_slack( + organisation_id=slack_organisation.id, + channel="#general", + text="Test message", + ) + + +async def test_get_installations_by_organisation_single( + slack_service: SlackService, slack_installation: SlackInstallation +) -> None: + """Test getting all installations when there's only one""" + installations = await slack_service.get_installations_by_organisation( + slack_installation.organisation_id + ) + + assert len(installations) == 1 + assert installations[0].id == slack_installation.id + + +async def test_get_installations_by_organisation_multiple( + conn_factory, slack_service: SlackService, slack_organisation: Organisation +) -> None: + """Test getting multiple installations for the same organisation (different channels)""" + # Create multiple installations for the same organisation with different channels + from core.integrations.slack.repo import SlackRepository + from core.uow import uow + + installation1 = SlackInstallationFactory.build( + organisation_id=slack_organisation.id, + team_id="T11111111", + team_name="Workspace 1", + bot_token="xoxb-token-1", + incoming_webhook_channel_id="C11111111", + ) + + installation2 = SlackInstallationFactory.build( + organisation_id=slack_organisation.id, + team_id="T22222222", + team_name="Workspace 2", + bot_token="xoxb-token-2", + incoming_webhook_channel_id="C22222222", + ) + + # Save both installations + async with uow(SlackRepository, conn_factory) as repo: + await repo.save_installation(installation1) + await repo.save_installation(installation2) + + # Get all installations + installations = await slack_service.get_installations_by_organisation( + slack_organisation.id + ) + + assert len(installations) == 2 + channel_ids = {inst.incoming_webhook_channel_id for inst in installations} + assert channel_ids == {"C11111111", "C22222222"} + + +async def test_get_installations_by_organisation_empty( + slack_service: SlackService, +) -> None: + """Test getting installations when none exist""" + installations = await slack_service.get_installations_by_organisation(uuid4()) + assert installations == [] + + +async def test_slack_installation_response_excludes_sensitive_fields( + slack_installation: SlackInstallation, +) -> None: + """Test that SlackInstallationResponse excludes sensitive credentials""" + # Create a response from the installation + response = SlackInstallationResponse.from_installation(slack_installation) + + # Verify public fields are present + assert response.id == slack_installation.id + assert response.organisation_id == slack_installation.organisation_id + assert response.team_id == slack_installation.team_id + assert response.team_name == slack_installation.team_name + assert response.bot_id == slack_installation.bot_id + assert response.incoming_webhook_channel == slack_installation.incoming_webhook_channel + + # Verify response model doesn't have sensitive fields + response_dict = response.model_dump() + assert "bot_token" not in response_dict + assert "user_token" not in response_dict + assert "incoming_webhook_url" not in response_dict + assert "incoming_webhook_configuration_url" not in response_dict + + # Verify the installation still has the sensitive fields (unchanged) + assert slack_installation.bot_token == "xoxb-test-token" + assert slack_installation.incoming_webhook_url is not None + + +async def test_delete_installation_with_token_revocation( + conn_factory, slack_service: SlackService, slack_installation: SlackInstallation +) -> None: + """Test deleting an installation revokes the bot token via Slack API""" + + # Mock the Slack API client's auth_revoke response + mock_revoke_response = {"ok": True, "revoked": True} + + with patch("core.integrations.slack.service.AsyncWebClient") as mock_client_class: + mock_client_instance = AsyncMock() + mock_client_instance.auth_revoke = AsyncMock(return_value=mock_revoke_response) + mock_client_class.return_value = mock_client_instance + + # Delete the installation + await slack_service.delete_installation( + organisation_id=slack_installation.organisation_id, + installation_id=slack_installation.id, + ) + + # Verify auth_revoke was called + mock_client_instance.auth_revoke.assert_called_once() + mock_client_class.assert_called_once_with(token=slack_installation.bot_token) + + # Verify the installation was deleted from database + async with uow(SlackRepository, conn_factory) as repo: + found = await repo.find_installations_by_organisation( + slack_installation.organisation_id + ) + assert found == [] + + +async def test_delete_installation_wrong_organisation( + slack_service: SlackService, slack_installation: SlackInstallation +) -> None: + """Test that deleting an installation from wrong organisation raises error""" + wrong_org_id = uuid4() + + with raises(ValueError, match="different organisation"): + await slack_service.delete_installation( + organisation_id=wrong_org_id, + installation_id=slack_installation.id, + ) + + +async def test_delete_installation_not_found( + slack_service: SlackService, slack_organisation: Organisation +) -> None: + """Test that deleting non-existent installation raises NotFoundError""" + from core.errors import NotFoundError + + non_existent_id = uuid4() + + with raises(NotFoundError, match="Installation not found"): + await slack_service.delete_installation( + organisation_id=slack_organisation.id, + installation_id=non_existent_id, + ) + + +async def test_delete_installation_continues_on_revoke_failure( + conn_factory, slack_service: SlackService, slack_installation: SlackInstallation +) -> None: + """Test that installation is deleted even if token revocation fails""" + + # Mock the Slack API client to raise an error + with patch("core.integrations.slack.service.AsyncWebClient") as mock_client_class: + mock_client_instance = AsyncMock() + mock_client_instance.auth_revoke = AsyncMock( + side_effect=Exception("Slack API error") + ) + mock_client_class.return_value = mock_client_instance + + # Delete should still succeed despite revoke error + await slack_service.delete_installation( + organisation_id=slack_installation.organisation_id, + installation_id=slack_installation.id, + ) + + # Verify the installation was still deleted from database + async with uow(SlackRepository, conn_factory) as repo: + found = await repo.find_installations_by_organisation( + slack_installation.organisation_id + ) + assert found == [] diff --git a/uv.lock b/uv.lock index 3a90b4f..e667b72 100644 --- a/uv.lock +++ b/uv.lock @@ -6,6 +6,95 @@ resolution-markers = [ "sys_platform == 'darwin'", ] +[[package]] +name = "aiohappyeyeballs" +version = "2.6.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/30/f84a107a9c4331c14b2b586036f40965c128aa4fee4dda5d3d51cb14ad54/aiohappyeyeballs-2.6.1.tar.gz", hash = "sha256:c3f9d0113123803ccadfdf3f0faa505bc78e6a72d1cc4806cbd719826e943558", size = 22760, upload-time = "2025-03-12T01:42:48.764Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0f/15/5bf3b99495fb160b63f95972b81750f18f7f4e02ad051373b669d17d44f2/aiohappyeyeballs-2.6.1-py3-none-any.whl", hash = "sha256:f349ba8f4b75cb25c99c5c2d84e997e485204d2902a9597802b0371f09331fb8", size = 15265, upload-time = "2025-03-12T01:42:47.083Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.13.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/42/32cf8e7704ceb4481406eb87161349abb46a57fee3f008ba9cb610968646/aiohttp-3.13.3.tar.gz", hash = "sha256:a949eee43d3782f2daae4f4a2819b2cb9b0c5d3b7f7a927067cc84dafdbb9f88", size = 7844556, upload-time = "2026-01-03T17:33:05.204Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/97/8a/12ca489246ca1faaf5432844adbfce7ff2cc4997733e0af120869345643a/aiohttp-3.13.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:5dff64413671b0d3e7d5918ea490bdccb97a4ad29b3f311ed423200b2203e01c", size = 734190, upload-time = "2026-01-03T17:30:45.832Z" }, + { url = "https://files.pythonhosted.org/packages/32/08/de43984c74ed1fca5c014808963cc83cb00d7bb06af228f132d33862ca76/aiohttp-3.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:87b9aab6d6ed88235aa2970294f496ff1a1f9adcd724d800e9b952395a80ffd9", size = 491783, upload-time = "2026-01-03T17:30:47.466Z" }, + { url = "https://files.pythonhosted.org/packages/17/f8/8dd2cf6112a5a76f81f81a5130c57ca829d101ad583ce57f889179accdda/aiohttp-3.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:425c126c0dc43861e22cb1c14ba4c8e45d09516d0a3ae0a3f7494b79f5f233a3", size = 490704, upload-time = "2026-01-03T17:30:49.373Z" }, + { url = "https://files.pythonhosted.org/packages/6d/40/a46b03ca03936f832bc7eaa47cfbb1ad012ba1be4790122ee4f4f8cba074/aiohttp-3.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7f9120f7093c2a32d9647abcaf21e6ad275b4fbec5b55969f978b1a97c7c86bf", size = 1720652, upload-time = "2026-01-03T17:30:50.974Z" }, + { url = "https://files.pythonhosted.org/packages/f7/7e/917fe18e3607af92657e4285498f500dca797ff8c918bd7d90b05abf6c2a/aiohttp-3.13.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:697753042d57f4bf7122cab985bf15d0cef23c770864580f5af4f52023a56bd6", size = 1692014, upload-time = "2026-01-03T17:30:52.729Z" }, + { url = "https://files.pythonhosted.org/packages/71/b6/cefa4cbc00d315d68973b671cf105b21a609c12b82d52e5d0c9ae61d2a09/aiohttp-3.13.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6de499a1a44e7de70735d0b39f67c8f25eb3d91eb3103be99ca0fa882cdd987d", size = 1759777, upload-time = "2026-01-03T17:30:54.537Z" }, + { url = "https://files.pythonhosted.org/packages/fb/e3/e06ee07b45e59e6d81498b591fc589629be1553abb2a82ce33efe2a7b068/aiohttp-3.13.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:37239e9f9a7ea9ac5bf6b92b0260b01f8a22281996da609206a84df860bc1261", size = 1861276, upload-time = "2026-01-03T17:30:56.512Z" }, + { url = "https://files.pythonhosted.org/packages/7c/24/75d274228acf35ceeb2850b8ce04de9dd7355ff7a0b49d607ee60c29c518/aiohttp-3.13.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f76c1e3fe7d7c8afad7ed193f89a292e1999608170dcc9751a7462a87dfd5bc0", size = 1743131, upload-time = "2026-01-03T17:30:58.256Z" }, + { url = "https://files.pythonhosted.org/packages/04/98/3d21dde21889b17ca2eea54fdcff21b27b93f45b7bb94ca029c31ab59dc3/aiohttp-3.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:fc290605db2a917f6e81b0e1e0796469871f5af381ce15c604a3c5c7e51cb730", size = 1556863, upload-time = "2026-01-03T17:31:00.445Z" }, + { url = "https://files.pythonhosted.org/packages/9e/84/da0c3ab1192eaf64782b03971ab4055b475d0db07b17eff925e8c93b3aa5/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4021b51936308aeea0367b8f006dc999ca02bc118a0cc78c303f50a2ff6afb91", size = 1682793, upload-time = "2026-01-03T17:31:03.024Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0f/5802ada182f575afa02cbd0ec5180d7e13a402afb7c2c03a9aa5e5d49060/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:49a03727c1bba9a97d3e93c9f93ca03a57300f484b6e935463099841261195d3", size = 1716676, upload-time = "2026-01-03T17:31:04.842Z" }, + { url = "https://files.pythonhosted.org/packages/3f/8c/714d53bd8b5a4560667f7bbbb06b20c2382f9c7847d198370ec6526af39c/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3d9908a48eb7416dc1f4524e69f1d32e5d90e3981e4e37eb0aa1cd18f9cfa2a4", size = 1733217, upload-time = "2026-01-03T17:31:06.868Z" }, + { url = "https://files.pythonhosted.org/packages/7d/79/e2176f46d2e963facea939f5be2d26368ce543622be6f00a12844d3c991f/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2712039939ec963c237286113c68dbad80a82a4281543f3abf766d9d73228998", size = 1552303, upload-time = "2026-01-03T17:31:08.958Z" }, + { url = "https://files.pythonhosted.org/packages/ab/6a/28ed4dea1759916090587d1fe57087b03e6c784a642b85ef48217b0277ae/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:7bfdc049127717581866fa4708791220970ce291c23e28ccf3922c700740fdc0", size = 1763673, upload-time = "2026-01-03T17:31:10.676Z" }, + { url = "https://files.pythonhosted.org/packages/e8/35/4a3daeb8b9fab49240d21c04d50732313295e4bd813a465d840236dd0ce1/aiohttp-3.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8057c98e0c8472d8846b9c79f56766bcc57e3e8ac7bfd510482332366c56c591", size = 1721120, upload-time = "2026-01-03T17:31:12.575Z" }, + { url = "https://files.pythonhosted.org/packages/bc/9f/d643bb3c5fb99547323e635e251c609fbbc660d983144cfebec529e09264/aiohttp-3.13.3-cp313-cp313-win32.whl", hash = "sha256:1449ceddcdbcf2e0446957863af03ebaaa03f94c090f945411b61269e2cb5daf", size = 427383, upload-time = "2026-01-03T17:31:14.382Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f1/ab0395f8a79933577cdd996dd2f9aa6014af9535f65dddcf88204682fe62/aiohttp-3.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:693781c45a4033d31d4187d2436f5ac701e7bbfe5df40d917736108c1cc7436e", size = 453899, upload-time = "2026-01-03T17:31:15.958Z" }, + { url = "https://files.pythonhosted.org/packages/99/36/5b6514a9f5d66f4e2597e40dea2e3db271e023eb7a5d22defe96ba560996/aiohttp-3.13.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:ea37047c6b367fd4bd632bff8077449b8fa034b69e812a18e0132a00fae6e808", size = 737238, upload-time = "2026-01-03T17:31:17.909Z" }, + { url = "https://files.pythonhosted.org/packages/f7/49/459327f0d5bcd8c6c9ca69e60fdeebc3622861e696490d8674a6d0cb90a6/aiohttp-3.13.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:6fc0e2337d1a4c3e6acafda6a78a39d4c14caea625124817420abceed36e2415", size = 492292, upload-time = "2026-01-03T17:31:19.919Z" }, + { url = "https://files.pythonhosted.org/packages/e8/0b/b97660c5fd05d3495b4eb27f2d0ef18dc1dc4eff7511a9bf371397ff0264/aiohttp-3.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c685f2d80bb67ca8c3837823ad76196b3694b0159d232206d1e461d3d434666f", size = 493021, upload-time = "2026-01-03T17:31:21.636Z" }, + { url = "https://files.pythonhosted.org/packages/54/d4/438efabdf74e30aeceb890c3290bbaa449780583b1270b00661126b8aae4/aiohttp-3.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:48e377758516d262bde50c2584fc6c578af272559c409eecbdd2bae1601184d6", size = 1717263, upload-time = "2026-01-03T17:31:23.296Z" }, + { url = "https://files.pythonhosted.org/packages/71/f2/7bddc7fd612367d1459c5bcf598a9e8f7092d6580d98de0e057eb42697ad/aiohttp-3.13.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:34749271508078b261c4abb1767d42b8d0c0cc9449c73a4df494777dc55f0687", size = 1669107, upload-time = "2026-01-03T17:31:25.334Z" }, + { url = "https://files.pythonhosted.org/packages/00/5a/1aeaecca40e22560f97610a329e0e5efef5e0b5afdf9f857f0d93839ab2e/aiohttp-3.13.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:82611aeec80eb144416956ec85b6ca45a64d76429c1ed46ae1b5f86c6e0c9a26", size = 1760196, upload-time = "2026-01-03T17:31:27.394Z" }, + { url = "https://files.pythonhosted.org/packages/f8/f8/0ff6992bea7bd560fc510ea1c815f87eedd745fe035589c71ce05612a19a/aiohttp-3.13.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2fff83cfc93f18f215896e3a190e8e5cb413ce01553901aca925176e7568963a", size = 1843591, upload-time = "2026-01-03T17:31:29.238Z" }, + { url = "https://files.pythonhosted.org/packages/e3/d1/e30e537a15f53485b61f5be525f2157da719819e8377298502aebac45536/aiohttp-3.13.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bbe7d4cecacb439e2e2a8a1a7b935c25b812af7a5fd26503a66dadf428e79ec1", size = 1720277, upload-time = "2026-01-03T17:31:31.053Z" }, + { url = "https://files.pythonhosted.org/packages/84/45/23f4c451d8192f553d38d838831ebbc156907ea6e05557f39563101b7717/aiohttp-3.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b928f30fe49574253644b1ca44b1b8adbd903aa0da4b9054a6c20fc7f4092a25", size = 1548575, upload-time = "2026-01-03T17:31:32.87Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ed/0a42b127a43712eda7807e7892c083eadfaf8429ca8fb619662a530a3aab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7b5e8fe4de30df199155baaf64f2fcd604f4c678ed20910db8e2c66dc4b11603", size = 1679455, upload-time = "2026-01-03T17:31:34.76Z" }, + { url = "https://files.pythonhosted.org/packages/2e/b5/c05f0c2b4b4fe2c9d55e73b6d3ed4fd6c9dc2684b1d81cbdf77e7fad9adb/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8542f41a62bcc58fc7f11cf7c90e0ec324ce44950003feb70640fc2a9092c32a", size = 1687417, upload-time = "2026-01-03T17:31:36.699Z" }, + { url = "https://files.pythonhosted.org/packages/c9/6b/915bc5dad66aef602b9e459b5a973529304d4e89ca86999d9d75d80cbd0b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:5e1d8c8b8f1d91cd08d8f4a3c2b067bfca6ec043d3ff36de0f3a715feeedf926", size = 1729968, upload-time = "2026-01-03T17:31:38.622Z" }, + { url = "https://files.pythonhosted.org/packages/11/3b/e84581290a9520024a08640b63d07673057aec5ca548177a82026187ba73/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:90455115e5da1c3c51ab619ac57f877da8fd6d73c05aacd125c5ae9819582aba", size = 1545690, upload-time = "2026-01-03T17:31:40.57Z" }, + { url = "https://files.pythonhosted.org/packages/f5/04/0c3655a566c43fd647c81b895dfe361b9f9ad6d58c19309d45cff52d6c3b/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:042e9e0bcb5fba81886c8b4fbb9a09d6b8a00245fd8d88e4d989c1f96c74164c", size = 1746390, upload-time = "2026-01-03T17:31:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/71165b26978f719c3419381514c9690bd5980e764a09440a10bb816ea4ab/aiohttp-3.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2eb752b102b12a76ca02dff751a801f028b4ffbbc478840b473597fc91a9ed43", size = 1702188, upload-time = "2026-01-03T17:31:44.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/a7/cbe6c9e8e136314fa1980da388a59d2f35f35395948a08b6747baebb6aa6/aiohttp-3.13.3-cp314-cp314-win32.whl", hash = "sha256:b556c85915d8efaed322bf1bdae9486aa0f3f764195a0fb6ee962e5c71ef5ce1", size = 433126, upload-time = "2026-01-03T17:31:47.463Z" }, + { url = "https://files.pythonhosted.org/packages/de/56/982704adea7d3b16614fc5936014e9af85c0e34b58f9046655817f04306e/aiohttp-3.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9bf9f7a65e7aa20dd764151fb3d616c81088f91f8df39c3893a536e279b4b984", size = 459128, upload-time = "2026-01-03T17:31:49.2Z" }, + { url = "https://files.pythonhosted.org/packages/6c/2a/3c79b638a9c3d4658d345339d22070241ea341ed4e07b5ac60fb0f418003/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:05861afbbec40650d8a07ea324367cb93e9e8cc7762e04dd4405df99fa65159c", size = 769512, upload-time = "2026-01-03T17:31:51.134Z" }, + { url = "https://files.pythonhosted.org/packages/29/b9/3e5014d46c0ab0db8707e0ac2711ed28c4da0218c358a4e7c17bae0d8722/aiohttp-3.13.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:2fc82186fadc4a8316768d61f3722c230e2c1dcab4200d52d2ebdf2482e47592", size = 506444, upload-time = "2026-01-03T17:31:52.85Z" }, + { url = "https://files.pythonhosted.org/packages/90/03/c1d4ef9a054e151cd7839cdc497f2638f00b93cbe8043983986630d7a80c/aiohttp-3.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0add0900ff220d1d5c5ebbf99ed88b0c1bbf87aa7e4262300ed1376a6b13414f", size = 510798, upload-time = "2026-01-03T17:31:54.91Z" }, + { url = "https://files.pythonhosted.org/packages/ea/76/8c1e5abbfe8e127c893fe7ead569148a4d5a799f7cf958d8c09f3eedf097/aiohttp-3.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:568f416a4072fbfae453dcf9a99194bbb8bdeab718e08ee13dfa2ba0e4bebf29", size = 1868835, upload-time = "2026-01-03T17:31:56.733Z" }, + { url = "https://files.pythonhosted.org/packages/8e/ac/984c5a6f74c363b01ff97adc96a3976d9c98940b8969a1881575b279ac5d/aiohttp-3.13.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:add1da70de90a2569c5e15249ff76a631ccacfe198375eead4aadf3b8dc849dc", size = 1720486, upload-time = "2026-01-03T17:31:58.65Z" }, + { url = "https://files.pythonhosted.org/packages/b2/9a/b7039c5f099c4eb632138728828b33428585031a1e658d693d41d07d89d1/aiohttp-3.13.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:10b47b7ba335d2e9b1239fa571131a87e2d8ec96b333e68b2a305e7a98b0bae2", size = 1847951, upload-time = "2026-01-03T17:32:00.989Z" }, + { url = "https://files.pythonhosted.org/packages/3c/02/3bec2b9a1ba3c19ff89a43a19324202b8eb187ca1e928d8bdac9bbdddebd/aiohttp-3.13.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4dce1c718e38081c8f35f323209d4c1df7d4db4bab1b5c88a6b4d12b74587", size = 1941001, upload-time = "2026-01-03T17:32:03.122Z" }, + { url = "https://files.pythonhosted.org/packages/37/df/d879401cedeef27ac4717f6426c8c36c3091c6e9f08a9178cc87549c537f/aiohttp-3.13.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34bac00a67a812570d4a460447e1e9e06fae622946955f939051e7cc895cfab8", size = 1797246, upload-time = "2026-01-03T17:32:05.255Z" }, + { url = "https://files.pythonhosted.org/packages/8d/15/be122de1f67e6953add23335c8ece6d314ab67c8bebb3f181063010795a7/aiohttp-3.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19884d2ee70b06d9204b2727a7b9f983d0c684c650254679e716b0b77920632", size = 1627131, upload-time = "2026-01-03T17:32:07.607Z" }, + { url = "https://files.pythonhosted.org/packages/12/12/70eedcac9134cfa3219ab7af31ea56bc877395b1ac30d65b1bc4b27d0438/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5f8ca7f2bb6ba8348a3614c7918cc4bb73268c5ac2a207576b7afea19d3d9f64", size = 1795196, upload-time = "2026-01-03T17:32:09.59Z" }, + { url = "https://files.pythonhosted.org/packages/32/11/b30e1b1cd1f3054af86ebe60df96989c6a414dd87e27ad16950eee420bea/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b0d95340658b9d2f11d9697f59b3814a9d3bb4b7a7c20b131df4bcef464037c0", size = 1782841, upload-time = "2026-01-03T17:32:11.445Z" }, + { url = "https://files.pythonhosted.org/packages/88/0d/d98a9367b38912384a17e287850f5695c528cff0f14f791ce8ee2e4f7796/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:a1e53262fd202e4b40b70c3aff944a8155059beedc8a89bba9dc1f9ef06a1b56", size = 1795193, upload-time = "2026-01-03T17:32:13.705Z" }, + { url = "https://files.pythonhosted.org/packages/43/a5/a2dfd1f5ff5581632c7f6a30e1744deda03808974f94f6534241ef60c751/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:d60ac9663f44168038586cab2157e122e46bdef09e9368b37f2d82d354c23f72", size = 1621979, upload-time = "2026-01-03T17:32:15.965Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/12973c382ae7c1cccbc4417e129c5bf54c374dfb85af70893646e1f0e749/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:90751b8eed69435bac9ff4e3d2f6b3af1f57e37ecb0fbeee59c0174c9e2d41df", size = 1822193, upload-time = "2026-01-03T17:32:18.219Z" }, + { url = "https://files.pythonhosted.org/packages/3c/5f/24155e30ba7f8c96918af1350eb0663e2430aad9e001c0489d89cd708ab1/aiohttp-3.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:fc353029f176fd2b3ec6cfc71be166aba1936fe5d73dd1992ce289ca6647a9aa", size = 1769801, upload-time = "2026-01-03T17:32:20.25Z" }, + { url = "https://files.pythonhosted.org/packages/eb/f8/7314031ff5c10e6ece114da79b338ec17eeff3a079e53151f7e9f43c4723/aiohttp-3.13.3-cp314-cp314t-win32.whl", hash = "sha256:2e41b18a58da1e474a057b3d35248d8320029f61d70a37629535b16a0c8f3767", size = 466523, upload-time = "2026-01-03T17:32:22.215Z" }, + { url = "https://files.pythonhosted.org/packages/b4/63/278a98c715ae467624eafe375542d8ba9b4383a016df8fdefe0ae28382a7/aiohttp-3.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:44531a36aa2264a1860089ffd4dce7baf875ee5a6079d5fb42e261c704ef7344", size = 499694, upload-time = "2026-01-03T17:32:24.546Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + [[package]] name = "annotated-types" version = "0.7.0" @@ -46,6 +135,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c9/7f/09065fd9e27da0eda08b4d6897f1c13535066174cc023af248fc2a8d5e5a/asn1crypto-1.5.1-py2.py3-none-any.whl", hash = "sha256:db4e40728b728508912cbb3d44f19ce188f218e9eba635821bb4b68564f8fd67", size = 105045, upload-time = "2022-03-15T14:46:51.055Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "bcrypt" version = "4.3.0" @@ -193,6 +291,7 @@ name = "core-api" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "aiohttp" }, { name = "bcrypt" }, { name = "click" }, { name = "email-validator" }, @@ -204,6 +303,7 @@ dependencies = [ { name = "psycopg", extra = ["binary", "pool"] }, { name = "python-i18n" }, { name = "sentence-transformers" }, + { name = "slack-sdk" }, { name = "torch", version = "2.7.1", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, { name = "torch", version = "2.7.1+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, ] @@ -223,6 +323,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aiohttp", specifier = ">=3.13.3" }, { name = "bcrypt", specifier = ">=4.3.0" }, { name = "click", specifier = ">=8.1.0" }, { name = "email-validator", specifier = ">=2.2.0" }, @@ -234,6 +335,7 @@ requires-dist = [ { name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.2.9" }, { name = "python-i18n", specifier = ">=0.3.9" }, { name = "sentence-transformers", specifier = ">=5.0.0" }, + { name = "slack-sdk", specifier = ">=3.41.0" }, { name = "torch", specifier = ">=2.7.1", index = "https://download.pytorch.org/whl/cpu" }, ] @@ -431,6 +533,79 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4d/36/2a115987e2d8c300a974597416d9de88f2444426de9571f4b59b2cca3acc/filelock-3.18.0-py3-none-any.whl", hash = "sha256:c401f4f8377c4464e6db25fff06205fd89bdd83b65eb0488ed1b160f780e21de", size = 16215, upload-time = "2025-03-14T07:11:39.145Z" }, ] +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + [[package]] name = "fsspec" version = "2025.5.1" @@ -1270,6 +1445,75 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/88/74/a88bf1b1efeae488a0c0b7bdf71429c313722d1fc0f377537fbe554e6180/pre_commit-4.2.0-py2.py3-none-any.whl", hash = "sha256:a009ca7205f1eb497d10b845e52c838a98b6cdd2102a6c8e4540e94ee75c58bd", size = 220707, upload-time = "2025-03-18T21:35:19.343Z" }, ] +[[package]] +name = "propcache" +version = "0.4.1" +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" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/df/6d9c1b6ac12b003837dde8a10231a7344512186e87b36e855bef32241942/propcache-0.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:43eedf29202c08550aac1d14e0ee619b0430aaef78f85864c1a892294fbc28cf", size = 77750, upload-time = "2025-10-08T19:47:07.648Z" }, + { url = "https://files.pythonhosted.org/packages/8b/e8/677a0025e8a2acf07d3418a2e7ba529c9c33caf09d3c1f25513023c1db56/propcache-0.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d62cdfcfd89ccb8de04e0eda998535c406bf5e060ffd56be6c586cbcc05b3311", size = 44780, upload-time = "2025-10-08T19:47:08.851Z" }, + { url = "https://files.pythonhosted.org/packages/89/a4/92380f7ca60f99ebae761936bc48a72a639e8a47b29050615eef757cb2a7/propcache-0.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:cae65ad55793da34db5f54e4029b89d3b9b9490d8abe1b4c7ab5d4b8ec7ebf74", size = 46308, upload-time = "2025-10-08T19:47:09.982Z" }, + { url = "https://files.pythonhosted.org/packages/2d/48/c5ac64dee5262044348d1d78a5f85dd1a57464a60d30daee946699963eb3/propcache-0.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:333ddb9031d2704a301ee3e506dc46b1fe5f294ec198ed6435ad5b6a085facfe", size = 208182, upload-time = "2025-10-08T19:47:11.319Z" }, + { url = "https://files.pythonhosted.org/packages/c6/0c/cd762dd011a9287389a6a3eb43aa30207bde253610cca06824aeabfe9653/propcache-0.4.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:fd0858c20f078a32cf55f7e81473d96dcf3b93fd2ccdb3d40fdf54b8573df3af", size = 211215, upload-time = "2025-10-08T19:47:13.146Z" }, + { url = "https://files.pythonhosted.org/packages/30/3e/49861e90233ba36890ae0ca4c660e95df565b2cd15d4a68556ab5865974e/propcache-0.4.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:678ae89ebc632c5c204c794f8dab2837c5f159aeb59e6ed0539500400577298c", size = 218112, upload-time = "2025-10-08T19:47:14.913Z" }, + { url = "https://files.pythonhosted.org/packages/f1/8b/544bc867e24e1bd48f3118cecd3b05c694e160a168478fa28770f22fd094/propcache-0.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d472aeb4fbf9865e0c6d622d7f4d54a4e101a89715d8904282bb5f9a2f476c3f", size = 204442, upload-time = "2025-10-08T19:47:16.277Z" }, + { url = "https://files.pythonhosted.org/packages/50/a6/4282772fd016a76d3e5c0df58380a5ea64900afd836cec2c2f662d1b9bb3/propcache-0.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4d3df5fa7e36b3225954fba85589da77a0fe6a53e3976de39caf04a0db4c36f1", size = 199398, upload-time = "2025-10-08T19:47:17.962Z" }, + { url = "https://files.pythonhosted.org/packages/3e/ec/d8a7cd406ee1ddb705db2139f8a10a8a427100347bd698e7014351c7af09/propcache-0.4.1-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:ee17f18d2498f2673e432faaa71698032b0127ebf23ae5974eeaf806c279df24", size = 196920, upload-time = "2025-10-08T19:47:19.355Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6c/f38ab64af3764f431e359f8baf9e0a21013e24329e8b85d2da32e8ed07ca/propcache-0.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:580e97762b950f993ae618e167e7be9256b8353c2dcd8b99ec100eb50f5286aa", size = 203748, upload-time = "2025-10-08T19:47:21.338Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e3/fa846bd70f6534d647886621388f0a265254d30e3ce47e5c8e6e27dbf153/propcache-0.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:501d20b891688eb8e7aa903021f0b72d5a55db40ffaab27edefd1027caaafa61", size = 205877, upload-time = "2025-10-08T19:47:23.059Z" }, + { url = "https://files.pythonhosted.org/packages/e2/39/8163fc6f3133fea7b5f2827e8eba2029a0277ab2c5beee6c1db7b10fc23d/propcache-0.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a0bd56e5b100aef69bd8562b74b46254e7c8812918d3baa700c8a8009b0af66", size = 199437, upload-time = "2025-10-08T19:47:24.445Z" }, + { url = "https://files.pythonhosted.org/packages/93/89/caa9089970ca49c7c01662bd0eeedfe85494e863e8043565aeb6472ce8fe/propcache-0.4.1-cp313-cp313-win32.whl", hash = "sha256:bcc9aaa5d80322bc2fb24bb7accb4a30f81e90ab8d6ba187aec0744bc302ad81", size = 37586, upload-time = "2025-10-08T19:47:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/f5/ab/f76ec3c3627c883215b5c8080debb4394ef5a7a29be811f786415fc1e6fd/propcache-0.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:381914df18634f5494334d201e98245c0596067504b9372d8cf93f4bb23e025e", size = 40790, upload-time = "2025-10-08T19:47:26.847Z" }, + { url = "https://files.pythonhosted.org/packages/59/1b/e71ae98235f8e2ba5004d8cb19765a74877abf189bc53fc0c80d799e56c3/propcache-0.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:8873eb4460fd55333ea49b7d189749ecf6e55bf85080f11b1c4530ed3034cba1", size = 37158, upload-time = "2025-10-08T19:47:27.961Z" }, + { url = "https://files.pythonhosted.org/packages/83/ce/a31bbdfc24ee0dcbba458c8175ed26089cf109a55bbe7b7640ed2470cfe9/propcache-0.4.1-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:92d1935ee1f8d7442da9c0c4fa7ac20d07e94064184811b685f5c4fada64553b", size = 81451, upload-time = "2025-10-08T19:47:29.445Z" }, + { url = "https://files.pythonhosted.org/packages/25/9c/442a45a470a68456e710d96cacd3573ef26a1d0a60067e6a7d5e655621ed/propcache-0.4.1-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:473c61b39e1460d386479b9b2f337da492042447c9b685f28be4f74d3529e566", size = 46374, upload-time = "2025-10-08T19:47:30.579Z" }, + { url = "https://files.pythonhosted.org/packages/f4/bf/b1d5e21dbc3b2e889ea4327044fb16312a736d97640fb8b6aa3f9c7b3b65/propcache-0.4.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:c0ef0aaafc66fbd87842a3fe3902fd889825646bc21149eafe47be6072725835", size = 48396, upload-time = "2025-10-08T19:47:31.79Z" }, + { url = "https://files.pythonhosted.org/packages/f4/04/5b4c54a103d480e978d3c8a76073502b18db0c4bc17ab91b3cb5092ad949/propcache-0.4.1-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f95393b4d66bfae908c3ca8d169d5f79cd65636ae15b5e7a4f6e67af675adb0e", size = 275950, upload-time = "2025-10-08T19:47:33.481Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c1/86f846827fb969c4b78b0af79bba1d1ea2156492e1b83dea8b8a6ae27395/propcache-0.4.1-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c07fda85708bc48578467e85099645167a955ba093be0a2dcba962195676e859", size = 273856, upload-time = "2025-10-08T19:47:34.906Z" }, + { url = "https://files.pythonhosted.org/packages/36/1d/fc272a63c8d3bbad6878c336c7a7dea15e8f2d23a544bda43205dfa83ada/propcache-0.4.1-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:af223b406d6d000830c6f65f1e6431783fc3f713ba3e6cc8c024d5ee96170a4b", size = 280420, upload-time = "2025-10-08T19:47:36.338Z" }, + { url = "https://files.pythonhosted.org/packages/07/0c/01f2219d39f7e53d52e5173bcb09c976609ba30209912a0680adfb8c593a/propcache-0.4.1-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a78372c932c90ee474559c5ddfffd718238e8673c340dc21fe45c5b8b54559a0", size = 263254, upload-time = "2025-10-08T19:47:37.692Z" }, + { url = "https://files.pythonhosted.org/packages/2d/18/cd28081658ce597898f0c4d174d4d0f3c5b6d4dc27ffafeef835c95eb359/propcache-0.4.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:564d9f0d4d9509e1a870c920a89b2fec951b44bf5ba7d537a9e7c1ccec2c18af", size = 261205, upload-time = "2025-10-08T19:47:39.659Z" }, + { url = "https://files.pythonhosted.org/packages/7a/71/1f9e22eb8b8316701c2a19fa1f388c8a3185082607da8e406a803c9b954e/propcache-0.4.1-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:17612831fda0138059cc5546f4d12a2aacfb9e47068c06af35c400ba58ba7393", size = 247873, upload-time = "2025-10-08T19:47:41.084Z" }, + { url = "https://files.pythonhosted.org/packages/4a/65/3d4b61f36af2b4eddba9def857959f1016a51066b4f1ce348e0cf7881f58/propcache-0.4.1-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:41a89040cb10bd345b3c1a873b2bf36413d48da1def52f268a055f7398514874", size = 262739, upload-time = "2025-10-08T19:47:42.51Z" }, + { url = "https://files.pythonhosted.org/packages/2a/42/26746ab087faa77c1c68079b228810436ccd9a5ce9ac85e2b7307195fd06/propcache-0.4.1-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:e35b88984e7fa64aacecea39236cee32dd9bd8c55f57ba8a75cf2399553f9bd7", size = 263514, upload-time = "2025-10-08T19:47:43.927Z" }, + { url = "https://files.pythonhosted.org/packages/94/13/630690fe201f5502d2403dd3cfd451ed8858fe3c738ee88d095ad2ff407b/propcache-0.4.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f8b465489f927b0df505cbe26ffbeed4d6d8a2bbc61ce90eb074ff129ef0ab1", size = 257781, upload-time = "2025-10-08T19:47:45.448Z" }, + { url = "https://files.pythonhosted.org/packages/92/f7/1d4ec5841505f423469efbfc381d64b7b467438cd5a4bbcbb063f3b73d27/propcache-0.4.1-cp313-cp313t-win32.whl", hash = "sha256:2ad890caa1d928c7c2965b48f3a3815c853180831d0e5503d35cf00c472f4717", size = 41396, upload-time = "2025-10-08T19:47:47.202Z" }, + { url = "https://files.pythonhosted.org/packages/48/f0/615c30622316496d2cbbc29f5985f7777d3ada70f23370608c1d3e081c1f/propcache-0.4.1-cp313-cp313t-win_amd64.whl", hash = "sha256:f7ee0e597f495cf415bcbd3da3caa3bd7e816b74d0d52b8145954c5e6fd3ff37", size = 44897, upload-time = "2025-10-08T19:47:48.336Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ca/6002e46eccbe0e33dcd4069ef32f7f1c9e243736e07adca37ae8c4830ec3/propcache-0.4.1-cp313-cp313t-win_arm64.whl", hash = "sha256:929d7cbe1f01bb7baffb33dc14eb5691c95831450a26354cd210a8155170c93a", size = 39789, upload-time = "2025-10-08T19:47:49.876Z" }, + { 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/5b/5a/bc7b4a4ef808fa59a816c17b20c4bef6884daebbdf627ff2a161da67da19/propcache-0.4.1-py3-none-any.whl", hash = "sha256:af2a6052aeb6cf17d3e46ee169099044fd8224cbaf75c76a2ef596e8163e2237", size = 13305, upload-time = "2025-10-08T19:49:00.792Z" }, +] + [[package]] name = "proto-plus" version = "1.26.1" @@ -1855,6 +2099,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" }, ] +[[package]] +name = "slack-sdk" +version = "3.41.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/35/fc009118a13187dd9731657c60138e5a7c2dea88681a7f04dc406af5da7d/slack_sdk-3.41.0.tar.gz", hash = "sha256:eb61eb12a65bebeca9cb5d36b3f799e836ed2be21b456d15df2627cfe34076ca", size = 250568, upload-time = "2026-03-12T16:10:11.381Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/df/2e4be347ff98281b505cc0ccf141408cdd25eb5ca9f3830deb361b2472d3/slack_sdk-3.41.0-py2.py3-none-any.whl", hash = "sha256:bb18dcdfff1413ec448e759cf807ec3324090993d8ab9111c74081623b692a89", size = 313885, upload-time = "2026-03-12T16:10:09.811Z" }, +] + [[package]] name = "sniffio" version = "1.3.1" @@ -2271,6 +2524,92 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/82/f56956041adef78f849db6b289b282e72b55ab8045a75abad81898c28d19/wrapt-1.17.2-py3-none-any.whl", hash = "sha256:b18f2d1533a71f069c7f82d524a52599053d4c7166e9dd374ae2136b7f40f7c8", size = 23594, upload-time = "2025-01-14T10:35:44.018Z" }, ] +[[package]] +name = "yarl" +version = "1.23.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/23/6e/beb1beec874a72f23815c1434518bfc4ed2175065173fb138c3705f658d4/yarl-1.23.0.tar.gz", hash = "sha256:53b1ea6ca88ebd4420379c330aea57e258408dd0df9af0992e5de2078dc9f5d5", size = 194676, upload-time = "2026-03-01T22:07:53.373Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9a/4b/a0a6e5d0ee8a2f3a373ddef8a4097d74ac901ac363eea1440464ccbe0898/yarl-1.23.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:16c6994ac35c3e74fb0ae93323bf8b9c2a9088d55946109489667c510a7d010e", size = 123796, upload-time = "2026-03-01T22:05:41.412Z" }, + { url = "https://files.pythonhosted.org/packages/67/b6/8925d68af039b835ae876db5838e82e76ec87b9782ecc97e192b809c4831/yarl-1.23.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:4a42e651629dafb64fd5b0286a3580613702b5809ad3f24934ea87595804f2c5", size = 86547, upload-time = "2026-03-01T22:05:42.841Z" }, + { url = "https://files.pythonhosted.org/packages/ae/50/06d511cc4b8e0360d3c94af051a768e84b755c5eb031b12adaaab6dec6e5/yarl-1.23.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c6b9461a2a8b47c65eef63bb1c76a4f1c119618ffa99ea79bc5bb1e46c5821b", size = 85854, upload-time = "2026-03-01T22:05:44.85Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f4/4e30b250927ffdab4db70da08b9b8d2194d7c7b400167b8fbeca1e4701ca/yarl-1.23.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2569b67d616eab450d262ca7cb9f9e19d2f718c70a8b88712859359d0ab17035", size = 98351, upload-time = "2026-03-01T22:05:46.836Z" }, + { url = "https://files.pythonhosted.org/packages/86/fc/4118c5671ea948208bdb1492d8b76bdf1453d3e73df051f939f563e7dcc5/yarl-1.23.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e9d9a4d06d3481eab79803beb4d9bd6f6a8e781ec078ac70d7ef2dcc29d1bea5", size = 92711, upload-time = "2026-03-01T22:05:48.316Z" }, + { url = "https://files.pythonhosted.org/packages/56/11/1ed91d42bd9e73c13dc9e7eb0dd92298d75e7ac4dd7f046ad0c472e231cd/yarl-1.23.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f514f6474e04179d3d33175ed3f3e31434d3130d42ec153540d5b157deefd735", size = 106014, upload-time = "2026-03-01T22:05:50.028Z" }, + { url = "https://files.pythonhosted.org/packages/ce/c9/74e44e056a23fbc33aca71779ef450ca648a5bc472bdad7a82339918f818/yarl-1.23.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fda207c815b253e34f7e1909840fd14299567b1c0eb4908f8c2ce01a41265401", size = 105557, upload-time = "2026-03-01T22:05:51.416Z" }, + { url = "https://files.pythonhosted.org/packages/66/fe/b1e10b08d287f518994f1e2ff9b6d26f0adeecd8dd7d533b01bab29a3eda/yarl-1.23.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34b6cf500e61c90f305094911f9acc9c86da1a05a7a3f5be9f68817043f486e4", size = 101559, upload-time = "2026-03-01T22:05:52.872Z" }, + { url = "https://files.pythonhosted.org/packages/72/59/c5b8d94b14e3d3c2a9c20cb100119fd534ab5a14b93673ab4cc4a4141ea5/yarl-1.23.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d7504f2b476d21653e4d143f44a175f7f751cd41233525312696c76aa3dbb23f", size = 100502, upload-time = "2026-03-01T22:05:54.954Z" }, + { url = "https://files.pythonhosted.org/packages/77/4f/96976cb54cbfc5c9fd73ed4c51804f92f209481d1fb190981c0f8a07a1d7/yarl-1.23.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:578110dd426f0d209d1509244e6d4a3f1a3e9077655d98c5f22583d63252a08a", size = 98027, upload-time = "2026-03-01T22:05:56.409Z" }, + { url = "https://files.pythonhosted.org/packages/63/6e/904c4f476471afdbad6b7e5b70362fb5810e35cd7466529a97322b6f5556/yarl-1.23.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:609d3614d78d74ebe35f54953c5bbd2ac647a7ddb9c30a5d877580f5e86b22f2", size = 95369, upload-time = "2026-03-01T22:05:58.141Z" }, + { url = "https://files.pythonhosted.org/packages/9d/40/acfcdb3b5f9d68ef499e39e04d25e141fe90661f9d54114556cf83be8353/yarl-1.23.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4966242ec68afc74c122f8459abd597afd7d8a60dc93d695c1334c5fd25f762f", size = 105565, upload-time = "2026-03-01T22:06:00.286Z" }, + { url = "https://files.pythonhosted.org/packages/5e/c6/31e28f3a6ba2869c43d124f37ea5260cac9c9281df803c354b31f4dd1f3c/yarl-1.23.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e0fd068364a6759bc794459f0a735ab151d11304346332489c7972bacbe9e72b", size = 99813, upload-time = "2026-03-01T22:06:01.712Z" }, + { url = "https://files.pythonhosted.org/packages/08/1f/6f65f59e72d54aa467119b63fc0b0b1762eff0232db1f4720cd89e2f4a17/yarl-1.23.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:39004f0ad156da43e86aa71f44e033de68a44e5a31fc53507b36dd253970054a", size = 105632, upload-time = "2026-03-01T22:06:03.188Z" }, + { url = "https://files.pythonhosted.org/packages/a3/c4/18b178a69935f9e7a338127d5b77d868fdc0f0e49becd286d51b3a18c61d/yarl-1.23.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e5723c01a56c5028c807c701aa66722916d2747ad737a046853f6c46f4875543", size = 101895, upload-time = "2026-03-01T22:06:04.651Z" }, + { url = "https://files.pythonhosted.org/packages/8f/54/f5b870b5505663911dba950a8e4776a0dbd51c9c54c0ae88e823e4b874a0/yarl-1.23.0-cp313-cp313-win32.whl", hash = "sha256:1b6b572edd95b4fa8df75de10b04bc81acc87c1c7d16bcdd2035b09d30acc957", size = 82356, upload-time = "2026-03-01T22:06:06.04Z" }, + { url = "https://files.pythonhosted.org/packages/7a/84/266e8da36879c6edcd37b02b547e2d9ecdfea776be49598e75696e3316e1/yarl-1.23.0-cp313-cp313-win_amd64.whl", hash = "sha256:baaf55442359053c7d62f6f8413a62adba3205119bcb6f49594894d8be47e5e3", size = 87515, upload-time = "2026-03-01T22:06:08.107Z" }, + { url = "https://files.pythonhosted.org/packages/00/fd/7e1c66efad35e1649114fa13f17485f62881ad58edeeb7f49f8c5e748bf9/yarl-1.23.0-cp313-cp313-win_arm64.whl", hash = "sha256:fb4948814a2a98e3912505f09c9e7493b1506226afb1f881825368d6fb776ee3", size = 81785, upload-time = "2026-03-01T22:06:10.181Z" }, + { url = "https://files.pythonhosted.org/packages/9c/fc/119dd07004f17ea43bb91e3ece6587759edd7519d6b086d16bfbd3319982/yarl-1.23.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:aecfed0b41aa72b7881712c65cf764e39ce2ec352324f5e0837c7048d9e6daaa", size = 130719, upload-time = "2026-03-01T22:06:11.708Z" }, + { url = "https://files.pythonhosted.org/packages/e6/0d/9f2348502fbb3af409e8f47730282cd6bc80dec6630c1e06374d882d6eb2/yarl-1.23.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:a41bcf68efd19073376eb8cf948b8d9be0af26256403e512bb18f3966f1f9120", size = 89690, upload-time = "2026-03-01T22:06:13.429Z" }, + { url = "https://files.pythonhosted.org/packages/50/93/e88f3c80971b42cfc83f50a51b9d165a1dbf154b97005f2994a79f212a07/yarl-1.23.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cde9a2ecd91668bcb7f077c4966d8ceddb60af01b52e6e3e2680e4cf00ad1a59", size = 89851, upload-time = "2026-03-01T22:06:15.53Z" }, + { url = "https://files.pythonhosted.org/packages/1c/07/61c9dd8ba8f86473263b4036f70fb594c09e99c0d9737a799dfd8bc85651/yarl-1.23.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5023346c4ee7992febc0068e7593de5fa2bf611848c08404b35ebbb76b1b0512", size = 95874, upload-time = "2026-03-01T22:06:17.553Z" }, + { url = "https://files.pythonhosted.org/packages/9e/e9/f9ff8ceefba599eac6abddcfb0b3bee9b9e636e96dbf54342a8577252379/yarl-1.23.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:d1009abedb49ae95b136a8904a3f71b342f849ffeced2d3747bf29caeda218c4", size = 88710, upload-time = "2026-03-01T22:06:19.004Z" }, + { url = "https://files.pythonhosted.org/packages/eb/78/0231bfcc5d4c8eec220bc2f9ef82cb4566192ea867a7c5b4148f44f6cbcd/yarl-1.23.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a8d00f29b42f534cc8aa3931cfe773b13b23e561e10d2b26f27a8d309b0e82a1", size = 101033, upload-time = "2026-03-01T22:06:21.203Z" }, + { url = "https://files.pythonhosted.org/packages/cd/9b/30ea5239a61786f18fd25797151a17fbb3be176977187a48d541b5447dd4/yarl-1.23.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:95451e6ce06c3e104556d73b559f5da6c34a069b6b62946d3ad66afcd51642ea", size = 100817, upload-time = "2026-03-01T22:06:22.738Z" }, + { url = "https://files.pythonhosted.org/packages/62/e2/a4980481071791bc83bce2b7a1a1f7adcabfa366007518b4b845e92eeee3/yarl-1.23.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:531ef597132086b6cf96faa7c6c1dcd0361dd5f1694e5cc30375907b9b7d3ea9", size = 97482, upload-time = "2026-03-01T22:06:24.21Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1e/304a00cf5f6100414c4b5a01fc7ff9ee724b62158a08df2f8170dfc72a2d/yarl-1.23.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:88f9fb0116fbfcefcab70f85cf4b74a2b6ce5d199c41345296f49d974ddb4123", size = 95949, upload-time = "2026-03-01T22:06:25.697Z" }, + { url = "https://files.pythonhosted.org/packages/68/03/093f4055ed4cae649ac53bca3d180bd37102e9e11d048588e9ab0c0108d0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:e7b0460976dc75cb87ad9cc1f9899a4b97751e7d4e77ab840fc9b6d377b8fd24", size = 95839, upload-time = "2026-03-01T22:06:27.309Z" }, + { url = "https://files.pythonhosted.org/packages/b9/28/4c75ebb108f322aa8f917ae10a8ffa4f07cae10a8a627b64e578617df6a0/yarl-1.23.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:115136c4a426f9da976187d238e84139ff6b51a20839aa6e3720cd1026d768de", size = 90696, upload-time = "2026-03-01T22:06:29.048Z" }, + { url = "https://files.pythonhosted.org/packages/23/9c/42c2e2dd91c1a570402f51bdf066bfdb1241c2240ba001967bad778e77b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:ead11956716a940c1abc816b7df3fa2b84d06eaed8832ca32f5c5e058c65506b", size = 100865, upload-time = "2026-03-01T22:06:30.525Z" }, + { url = "https://files.pythonhosted.org/packages/74/05/1bcd60a8a0a914d462c305137246b6f9d167628d73568505fce3f1cb2e65/yarl-1.23.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:fe8f8f5e70e6dbdfca9882cd9deaac058729bcf323cf7a58660901e55c9c94f6", size = 96234, upload-time = "2026-03-01T22:06:32.692Z" }, + { url = "https://files.pythonhosted.org/packages/90/b2/f52381aac396d6778ce516b7bc149c79e65bfc068b5de2857ab69eeea3b7/yarl-1.23.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:a0e317df055958a0c1e79e5d2aa5a5eaa4a6d05a20d4b0c9c3f48918139c9fc6", size = 100295, upload-time = "2026-03-01T22:06:34.268Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/638bae5bbf1113a659b2435d8895474598afe38b4a837103764f603aba56/yarl-1.23.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6f0fd84de0c957b2d280143522c4f91a73aada1923caee763e24a2b3fda9f8a5", size = 97784, upload-time = "2026-03-01T22:06:35.864Z" }, + { url = "https://files.pythonhosted.org/packages/80/25/a3892b46182c586c202629fc2159aa13975d3741d52ebd7347fd501d48d5/yarl-1.23.0-cp313-cp313t-win32.whl", hash = "sha256:93a784271881035ab4406a172edb0faecb6e7d00f4b53dc2f55919d6c9688595", size = 88313, upload-time = "2026-03-01T22:06:37.39Z" }, + { url = "https://files.pythonhosted.org/packages/43/68/8c5b36aa5178900b37387937bc2c2fe0e9505537f713495472dcf6f6fccc/yarl-1.23.0-cp313-cp313t-win_amd64.whl", hash = "sha256:dd00607bffbf30250fe108065f07453ec124dbf223420f57f5e749b04295e090", size = 94932, upload-time = "2026-03-01T22:06:39.579Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cc/d79ba8292f51f81f4dc533a8ccfb9fc6992cabf0998ed3245de7589dc07c/yarl-1.23.0-cp313-cp313t-win_arm64.whl", hash = "sha256:ac09d42f48f80c9ee1635b2fcaa819496a44502737660d3c0f2ade7526d29144", size = 84786, upload-time = "2026-03-01T22:06:41.988Z" }, + { url = "https://files.pythonhosted.org/packages/90/98/b85a038d65d1b92c3903ab89444f48d3cee490a883477b716d7a24b1a78c/yarl-1.23.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:21d1b7305a71a15b4794b5ff22e8eef96ff4a6d7f9657155e5aa419444b28912", size = 124455, upload-time = "2026-03-01T22:06:43.615Z" }, + { url = "https://files.pythonhosted.org/packages/39/54/bc2b45559f86543d163b6e294417a107bb87557609007c007ad889afec18/yarl-1.23.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:85610b4f27f69984932a7abbe52703688de3724d9f72bceb1cca667deff27474", size = 86752, upload-time = "2026-03-01T22:06:45.425Z" }, + { url = "https://files.pythonhosted.org/packages/24/f9/e8242b68362bffe6fb536c8db5076861466fc780f0f1b479fc4ffbebb128/yarl-1.23.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23f371bd662cf44a7630d4d113101eafc0cfa7518a2760d20760b26021454719", size = 86291, upload-time = "2026-03-01T22:06:46.974Z" }, + { url = "https://files.pythonhosted.org/packages/ea/d8/d1cb2378c81dd729e98c716582b1ccb08357e8488e4c24714658cc6630e8/yarl-1.23.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c4a80f77dc1acaaa61f0934176fccca7096d9b1ff08c8ba9cddf5ae034a24319", size = 99026, upload-time = "2026-03-01T22:06:48.459Z" }, + { url = "https://files.pythonhosted.org/packages/0a/ff/7196790538f31debe3341283b5b0707e7feb947620fc5e8236ef28d44f72/yarl-1.23.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:bd654fad46d8d9e823afbb4f87c79160b5a374ed1ff5bde24e542e6ba8f41434", size = 92355, upload-time = "2026-03-01T22:06:50.306Z" }, + { url = "https://files.pythonhosted.org/packages/c1/56/25d58c3eddde825890a5fe6aa1866228377354a3c39262235234ab5f616b/yarl-1.23.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:682bae25f0a0dd23a056739f23a134db9f52a63e2afd6bfb37ddc76292bbd723", size = 106417, upload-time = "2026-03-01T22:06:52.1Z" }, + { url = "https://files.pythonhosted.org/packages/51/8a/882c0e7bc8277eb895b31bce0138f51a1ba551fc2e1ec6753ffc1e7c1377/yarl-1.23.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a82836cab5f197a0514235aaf7ffccdc886ccdaa2324bc0aafdd4ae898103039", size = 106422, upload-time = "2026-03-01T22:06:54.424Z" }, + { url = "https://files.pythonhosted.org/packages/42/2b/fef67d616931055bf3d6764885990a3ac647d68734a2d6a9e1d13de437a2/yarl-1.23.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c57676bdedc94cd3bc37724cf6f8cd2779f02f6aba48de45feca073e714fe52", size = 101915, upload-time = "2026-03-01T22:06:55.895Z" }, + { url = "https://files.pythonhosted.org/packages/18/6a/530e16aebce27c5937920f3431c628a29a4b6b430fab3fd1c117b26ff3f6/yarl-1.23.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c7f8dc16c498ff06497c015642333219871effba93e4a2e8604a06264aca5c5c", size = 100690, upload-time = "2026-03-01T22:06:58.21Z" }, + { url = "https://files.pythonhosted.org/packages/88/08/93749219179a45e27b036e03260fda05190b911de8e18225c294ac95bbc9/yarl-1.23.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5ee586fb17ff8f90c91cf73c6108a434b02d69925f44f5f8e0d7f2f260607eae", size = 98750, upload-time = "2026-03-01T22:06:59.794Z" }, + { url = "https://files.pythonhosted.org/packages/d9/cf/ea424a004969f5d81a362110a6ac1496d79efdc6d50c2c4b2e3ea0fc2519/yarl-1.23.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:17235362f580149742739cc3828b80e24029d08cbb9c4bda0242c7b5bc610a8e", size = 94685, upload-time = "2026-03-01T22:07:01.375Z" }, + { url = "https://files.pythonhosted.org/packages/e2/b7/14341481fe568e2b0408bcf1484c652accafe06a0ade9387b5d3fd9df446/yarl-1.23.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0793e2bd0cf14234983bbb371591e6bea9e876ddf6896cdcc93450996b0b5c85", size = 106009, upload-time = "2026-03-01T22:07:03.151Z" }, + { url = "https://files.pythonhosted.org/packages/0a/e6/5c744a9b54f4e8007ad35bce96fbc9218338e84812d36f3390cea616881a/yarl-1.23.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3650dc2480f94f7116c364096bc84b1d602f44224ef7d5c7208425915c0475dd", size = 100033, upload-time = "2026-03-01T22:07:04.701Z" }, + { url = "https://files.pythonhosted.org/packages/0c/23/e3bfc188d0b400f025bc49d99793d02c9abe15752138dcc27e4eaf0c4a9e/yarl-1.23.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:f40e782d49630ad384db66d4d8b73ff4f1b8955dc12e26b09a3e3af064b3b9d6", size = 106483, upload-time = "2026-03-01T22:07:06.231Z" }, + { url = "https://files.pythonhosted.org/packages/72/42/f0505f949a90b3f8b7a363d6cbdf398f6e6c58946d85c6d3a3bc70595b26/yarl-1.23.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94f8575fbdf81749008d980c17796097e645574a3b8c28ee313931068dad14fe", size = 102175, upload-time = "2026-03-01T22:07:08.4Z" }, + { url = "https://files.pythonhosted.org/packages/aa/65/b39290f1d892a9dd671d1c722014ca062a9c35d60885d57e5375db0404b5/yarl-1.23.0-cp314-cp314-win32.whl", hash = "sha256:c8aa34a5c864db1087d911a0b902d60d203ea3607d91f615acd3f3108ac32169", size = 83871, upload-time = "2026-03-01T22:07:09.968Z" }, + { url = "https://files.pythonhosted.org/packages/a9/5b/9b92f54c784c26e2a422e55a8d2607ab15b7ea3349e28359282f84f01d43/yarl-1.23.0-cp314-cp314-win_amd64.whl", hash = "sha256:63e92247f383c85ab00dd0091e8c3fa331a96e865459f5ee80353c70a4a42d70", size = 89093, upload-time = "2026-03-01T22:07:11.501Z" }, + { url = "https://files.pythonhosted.org/packages/e0/7d/8a84dc9381fd4412d5e7ff04926f9865f6372b4c2fd91e10092e65d29eb8/yarl-1.23.0-cp314-cp314-win_arm64.whl", hash = "sha256:70efd20be968c76ece7baa8dafe04c5be06abc57f754d6f36f3741f7aa7a208e", size = 83384, upload-time = "2026-03-01T22:07:13.069Z" }, + { url = "https://files.pythonhosted.org/packages/dd/8d/d2fad34b1c08aa161b74394183daa7d800141aaaee207317e82c790b418d/yarl-1.23.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:9a18d6f9359e45722c064c97464ec883eb0e0366d33eda61cb19a244bf222679", size = 131019, upload-time = "2026-03-01T22:07:14.903Z" }, + { url = "https://files.pythonhosted.org/packages/19/ff/33009a39d3ccf4b94d7d7880dfe17fb5816c5a4fe0096d9b56abceea9ac7/yarl-1.23.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2803ed8b21ca47a43da80a6fd1ed3019d30061f7061daa35ac54f63933409412", size = 89894, upload-time = "2026-03-01T22:07:17.372Z" }, + { url = "https://files.pythonhosted.org/packages/0c/f1/dab7ac5e7306fb79c0190766a3c00b4cb8d09a1f390ded68c85a5934faf5/yarl-1.23.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:394906945aa8b19fc14a61cf69743a868bb8c465efe85eee687109cc540b98f4", size = 89979, upload-time = "2026-03-01T22:07:19.361Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b1/08e95f3caee1fad6e65017b9f26c1d79877b502622d60e517de01e72f95d/yarl-1.23.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:71d006bee8397a4a89f469b8deb22469fe7508132d3c17fa6ed871e79832691c", size = 95943, upload-time = "2026-03-01T22:07:21.266Z" }, + { url = "https://files.pythonhosted.org/packages/c0/cc/6409f9018864a6aa186c61175b977131f373f1988e198e031236916e87e4/yarl-1.23.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:62694e275c93d54f7ccedcfef57d42761b2aad5234b6be1f3e3026cae4001cd4", size = 88786, upload-time = "2026-03-01T22:07:23.129Z" }, + { url = "https://files.pythonhosted.org/packages/76/40/cc22d1d7714b717fde2006fad2ced5efe5580606cb059ae42117542122f3/yarl-1.23.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a31de1613658308efdb21ada98cbc86a97c181aa050ba22a808120bb5be3ab94", size = 101307, upload-time = "2026-03-01T22:07:24.689Z" }, + { url = "https://files.pythonhosted.org/packages/8f/0d/476c38e85ddb4c6ec6b20b815bdd779aa386a013f3d8b85516feee55c8dc/yarl-1.23.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fb1e8b8d66c278b21d13b0a7ca22c41dd757a7c209c6b12c313e445c31dd3b28", size = 100904, upload-time = "2026-03-01T22:07:26.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/32/0abe4a76d59adf2081dcb0397168553ece4616ada1c54d1c49d8936c74f8/yarl-1.23.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:50f9d8d531dfb767c565f348f33dd5139a6c43f5cbdf3f67da40d54241df93f6", size = 97728, upload-time = "2026-03-01T22:07:27.906Z" }, + { url = "https://files.pythonhosted.org/packages/b7/35/7b30f4810fba112f60f5a43237545867504e15b1c7647a785fbaf588fac2/yarl-1.23.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575aa4405a656e61a540f4a80eaa5260f2a38fff7bfdc4b5f611840d76e9e277", size = 95964, upload-time = "2026-03-01T22:07:30.198Z" }, + { url = "https://files.pythonhosted.org/packages/2d/86/ed7a73ab85ef00e8bb70b0cb5421d8a2a625b81a333941a469a6f4022828/yarl-1.23.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:041b1a4cefacf65840b4e295c6985f334ba83c30607441ae3cf206a0eed1a2e4", size = 95882, upload-time = "2026-03-01T22:07:32.132Z" }, + { url = "https://files.pythonhosted.org/packages/19/90/d56967f61a29d8498efb7afb651e0b2b422a1e9b47b0ab5f4e40a19b699b/yarl-1.23.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:d38c1e8231722c4ce40d7593f28d92b5fc72f3e9774fe73d7e800ec32299f63a", size = 90797, upload-time = "2026-03-01T22:07:34.404Z" }, + { url = "https://files.pythonhosted.org/packages/72/00/8b8f76909259f56647adb1011d7ed8b321bcf97e464515c65016a47ecdf0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d53834e23c015ee83a99377db6e5e37d8484f333edb03bd15b4bc312cc7254fb", size = 101023, upload-time = "2026-03-01T22:07:35.953Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e2/cab11b126fb7d440281b7df8e9ddbe4851e70a4dde47a202b6642586b8d9/yarl-1.23.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:2e27c8841126e017dd2a054a95771569e6070b9ee1b133366d8b31beb5018a41", size = 96227, upload-time = "2026-03-01T22:07:37.594Z" }, + { url = "https://files.pythonhosted.org/packages/c2/9b/2c893e16bfc50e6b2edf76c1a9eb6cb0c744346197e74c65e99ad8d634d0/yarl-1.23.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:76855800ac56f878847a09ce6dba727c93ca2d89c9e9d63002d26b916810b0a2", size = 100302, upload-time = "2026-03-01T22:07:39.334Z" }, + { url = "https://files.pythonhosted.org/packages/28/ec/5498c4e3a6d5f1003beb23405671c2eb9cdbf3067d1c80f15eeafe301010/yarl-1.23.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:e09fd068c2e169a7070d83d3bde728a4d48de0549f975290be3c108c02e499b4", size = 98202, upload-time = "2026-03-01T22:07:41.717Z" }, + { url = "https://files.pythonhosted.org/packages/fe/c3/cd737e2d45e70717907f83e146f6949f20cc23cd4bf7b2688727763aa458/yarl-1.23.0-cp314-cp314t-win32.whl", hash = "sha256:73309162a6a571d4cbd3b6a1dcc703c7311843ae0d1578df6f09be4e98df38d4", size = 90558, upload-time = "2026-03-01T22:07:43.433Z" }, + { url = "https://files.pythonhosted.org/packages/e1/19/3774d162f6732d1cfb0b47b4140a942a35ca82bb19b6db1f80e9e7bdc8f8/yarl-1.23.0-cp314-cp314t-win_amd64.whl", hash = "sha256:4503053d296bc6e4cbd1fad61cf3b6e33b939886c4f249ba7c78b602214fabe2", size = 97610, upload-time = "2026-03-01T22:07:45.773Z" }, + { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, + { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, +] + [[package]] name = "zipp" version = "3.23.0"