this repo has no description
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

mostly formatting

+235 -371
+1 -1
netdata_zulip_bot/__init__.py
··· 1 1 """Netdata Zulip Bot - Webhook service for Netdata Cloud notifications.""" 2 2 3 - __version__ = "0.1.0" 3 + __version__ = "0.1.0"
+32 -29
netdata_zulip_bot/config.py
··· 2 2 3 3 import os 4 4 from pathlib import Path 5 - from typing import Optional 6 5 7 6 import structlog 8 7 from dotenv import load_dotenv 9 8 10 - from .models import ZulipConfig, ServerConfig 9 + from .models import ServerConfig, ZulipConfig 11 10 12 11 logger = structlog.get_logger() 13 12 14 13 15 14 def load_config() -> tuple[ZulipConfig, ServerConfig]: 16 15 """Load configuration from environment variables and .env files.""" 17 - 16 + 18 17 # Load .env file if present 19 18 env_file = Path(".env") 20 19 if env_file.exists(): 21 20 load_dotenv(env_file) 22 21 logger.info("Loaded configuration from .env file") 23 - 22 + 24 23 # Load Zulip configuration (optional from env, main source should be zuliprc) 25 24 zulip_config = ZulipConfig( 26 25 site=os.getenv("ZULIP_SITE"), ··· 28 27 api_key=os.getenv("ZULIP_API_KEY"), 29 28 stream=os.getenv("ZULIP_STREAM", "netdata-alerts"), 30 29 ) 31 - 30 + 32 31 # Load server configuration 33 32 server_config = ServerConfig( 34 33 host=os.getenv("SERVER_HOST", "0.0.0.0"), 35 34 port=int(os.getenv("SERVER_PORT", "8080")), 36 35 challenge_secret=os.getenv("SERVER_CHALLENGE_SECRET"), 37 36 ) 38 - 37 + 39 38 logger.info( 40 39 "Configuration loaded", 41 40 zulip_site=zulip_config.site or "(from zuliprc)", ··· 44 43 server_host=server_config.host, 45 44 server_port=server_config.port, 46 45 ) 47 - 46 + 48 47 return zulip_config, server_config 49 48 50 49 51 - def load_zuliprc_config(zuliprc_path: Optional[str] = None) -> ZulipConfig: 50 + def load_zuliprc_config(zuliprc_path: str | None = None) -> ZulipConfig: 52 51 """Load Zulip configuration from a zuliprc file. 53 - 52 + 54 53 Args: 55 54 zuliprc_path: Path to zuliprc file. If None, looks for ~/.zuliprc 56 - 55 + 57 56 Returns: 58 57 ZulipConfig instance 59 58 """ ··· 61 60 zuliprc_path = Path.home() / ".zuliprc" 62 61 else: 63 62 zuliprc_path = Path(zuliprc_path) 64 - 63 + 65 64 if not zuliprc_path.exists(): 66 65 raise FileNotFoundError(f"Zuliprc file not found: {zuliprc_path}") 67 - 66 + 68 67 config = {} 69 - with open(zuliprc_path, 'r') as f: 68 + with open(zuliprc_path) as f: 70 69 for line in f: 71 70 line = line.strip() 72 - if line and not line.startswith('#') and '=' in line: 73 - key, value = line.split('=', 1) 71 + if line and not line.startswith("#") and "=" in line: 72 + key, value = line.split("=", 1) 74 73 config[key.strip()] = value.strip() 75 - 74 + 76 75 # Map zuliprc keys to our config 77 76 zulip_config = ZulipConfig( 78 - site=config.get('site', ''), 79 - email=config.get('email', ''), 80 - api_key=config.get('key', ''), 81 - stream=config.get('stream', 'netdata-alerts'), 77 + site=config.get("site", ""), 78 + email=config.get("email", ""), 79 + api_key=config.get("key", ""), 80 + stream=config.get("stream", "netdata-alerts"), 82 81 ) 83 - 82 + 84 83 # Validate required fields from zuliprc 85 84 if not all([zulip_config.site, zulip_config.email, zulip_config.api_key]): 86 85 missing = [] 87 - if not zulip_config.site: missing.append('site') 88 - if not zulip_config.email: missing.append('email') 89 - if not zulip_config.api_key: missing.append('key') 86 + if not zulip_config.site: 87 + missing.append("site") 88 + if not zulip_config.email: 89 + missing.append("email") 90 + if not zulip_config.api_key: 91 + missing.append("key") 90 92 raise ValueError( 91 - f"Missing required Zulip configuration in {zuliprc_path}: {', '.join(missing)}" 93 + f"Missing required Zulip configuration in {zuliprc_path}: " 94 + f"{', '.join(missing)}" 92 95 ) 93 - 96 + 94 97 logger.info( 95 98 "Loaded Zulip configuration from zuliprc", 96 99 path=str(zuliprc_path), 97 100 site=zulip_config.site, 98 101 email=zulip_config.email, 99 - stream=zulip_config.stream 102 + stream=zulip_config.stream, 100 103 ) 101 - 102 - return zulip_config 104 + 105 + return zulip_config
+24 -25
netdata_zulip_bot/formatter.py
··· 1 1 """Message formatting for Zulip notifications.""" 2 2 3 - from typing import Union 4 - 5 3 from .models import AlertNotification, ReachabilityNotification, TestNotification 6 4 7 5 8 6 class ZulipMessageFormatter: 9 7 """Format Netdata notifications for Zulip messages.""" 10 - 8 + 11 9 def format_alert(self, alert: AlertNotification) -> tuple[str, str]: 12 10 """Format alert notification for Zulip. 13 - 11 + 14 12 Returns: 15 13 Tuple of (topic, message_content) 16 14 """ 17 15 topic = alert.severity.value 18 - 16 + 19 17 # Severity emoji mapping 20 - severity_emoji = { 21 - "critical": "🔴", 22 - "warning": "⚠️", 23 - "clear": "✅" 24 - } 25 - 18 + severity_emoji = {"critical": "🔴", "warning": "⚠️", "clear": "✅"} 19 + 26 20 emoji = severity_emoji.get(alert.severity.value, "📊") 27 - 21 + 28 22 message = f"""{emoji} **{alert.alert}** 29 23 30 24 **Space:** {alert.space} 31 25 **Chart:** {alert.chart} 32 26 **Context:** {alert.context} 33 27 **Severity:** {alert.severity.value.title()} 34 - **Time:** {alert.date.strftime('%Y-%m-%d %H:%M:%S UTC')} 28 + **Time:** {alert.date.strftime("%Y-%m-%d %H:%M:%S UTC")} 35 29 36 30 **Details:** {alert.info} 37 31 38 32 **Summary:** {alert.message} 39 33 40 34 [View Alert]({alert.alert_url})""" 41 - 35 + 42 36 return topic, message 43 37 44 - def format_reachability(self, notification: ReachabilityNotification) -> tuple[str, str]: 38 + def format_reachability( 39 + self, notification: ReachabilityNotification 40 + ) -> tuple[str, str]: 45 41 """Format reachability notification for Zulip. 46 - 42 + 47 43 Returns: 48 44 Tuple of (topic, message_content) 49 45 """ 50 46 topic = "reachability" 51 - 47 + 52 48 status_emoji = "✅" if notification.status.reachable else "❌" 53 49 severity_emoji = "🔴" if notification.severity.value == "critical" else "ℹ️" 54 - 50 + 55 51 message = f"""{severity_emoji} **Host {notification.status.text.title()}** 56 52 57 53 **Host:** {notification.host} ··· 61 57 **Summary:** {notification.message} 62 58 63 59 [View Host]({notification.url})""" 64 - 60 + 65 61 return topic, message 66 62 67 63 def format_test(self, notification: TestNotification) -> tuple[str, str]: 68 64 """Format test notification for Zulip. 69 - 65 + 70 66 Returns: 71 67 Tuple of (topic, message_content) 72 68 """ 73 69 topic = "test" 74 - 70 + 75 71 message = f"""🧪 **Netdata Webhook Test** 76 72 77 73 {notification.message} 78 74 79 75 Your webhook integration is working correctly! ✅""" 80 - 76 + 81 77 return topic, message 82 78 83 - def format_notification(self, notification: Union[AlertNotification, ReachabilityNotification, TestNotification]) -> tuple[str, str]: 79 + def format_notification( 80 + self, 81 + notification: (AlertNotification | ReachabilityNotification | TestNotification), 82 + ) -> tuple[str, str]: 84 83 """Format any notification type for Zulip. 85 - 84 + 86 85 Returns: 87 86 Tuple of (topic, message_content) 88 87 """ ··· 93 92 elif isinstance(notification, TestNotification): 94 93 return self.format_test(notification) 95 94 else: 96 - raise ValueError(f"Unknown notification type: {type(notification)}") 95 + raise ValueError(f"Unknown notification type: {type(notification)}")
+29 -26
netdata_zulip_bot/main.py
··· 2 2 3 3 import argparse 4 4 import sys 5 - from pathlib import Path 6 5 7 6 import structlog 8 7 9 8 from .config import load_config, load_zuliprc_config 10 - from .models import ServerConfig 11 9 from .server import NetdataWebhookServer 12 10 13 11 ··· 23 21 structlog.processors.StackInfoRenderer(), 24 22 structlog.processors.format_exc_info, 25 23 structlog.processors.UnicodeDecoder(), 26 - structlog.processors.JSONRenderer() 24 + structlog.processors.JSONRenderer(), 27 25 ], 28 26 context_class=dict, 29 27 logger_factory=structlog.stdlib.LoggerFactory(), ··· 34 32 35 33 def create_sample_configs(): 36 34 """Create sample configuration files.""" 37 - 35 + 38 36 # Sample .env file 39 37 env_content = """# Server Configuration (HTTP only, TLS handled by reverse proxy) 40 38 SERVER_HOST=0.0.0.0 ··· 46 44 # Optional: Override Zulip stream (default: netdata-alerts) 47 45 # ZULIP_STREAM=custom-alerts-stream 48 46 """ 49 - 50 - with open(".env.sample", 'w') as f: 47 + 48 + with open(".env.sample", "w") as f: 51 49 f.write(env_content) 52 - 50 + 53 51 # Sample zuliprc file 54 52 zuliprc_content = """[api] 55 53 site=https://yourorg.zulipchat.com ··· 57 55 key=your-api-key-here 58 56 stream=netdata-alerts 59 57 """ 60 - 61 - with open(".zuliprc.sample", 'w') as f: 58 + 59 + with open(".zuliprc.sample", "w") as f: 62 60 f.write(zuliprc_content) 63 - 61 + 64 62 print("Created sample configuration files:") 65 63 print(" - .env.sample") 66 64 print(" - .zuliprc.sample") ··· 73 71 def main(): 74 72 """Main entry point.""" 75 73 parser = argparse.ArgumentParser( 76 - description="Netdata Zulip Bot - Webhook service for Netdata Cloud notifications" 74 + description=( 75 + "Netdata Zulip Bot - Webhook service for Netdata Cloud notifications" 76 + ) 77 77 ) 78 78 parser.add_argument( 79 - "--zuliprc", 80 - help="Path to zuliprc configuration file (default: ~/.zuliprc)" 79 + "--zuliprc", help="Path to zuliprc configuration file (default: ~/.zuliprc)" 81 80 ) 82 81 parser.add_argument( 83 82 "--create-config", 84 83 action="store_true", 85 - help="Create sample configuration files and exit" 84 + help="Create sample configuration files and exit", 86 85 ) 87 86 parser.add_argument( 88 87 "--env-config", 89 88 action="store_true", 90 - help="Use environment variables for configuration instead of zuliprc" 89 + help="Use environment variables for configuration instead of zuliprc", 91 90 ) 92 - 91 + 93 92 args = parser.parse_args() 94 - 93 + 95 94 setup_logging() 96 95 logger = structlog.get_logger() 97 - 96 + 98 97 if args.create_config: 99 98 create_sample_configs() 100 99 return 101 - 100 + 102 101 try: 103 102 # Load configuration 104 103 if args.env_config: ··· 106 105 # Validate that required Zulip fields are provided via environment 107 106 if not all([zulip_config.site, zulip_config.email, zulip_config.api_key]): 108 107 missing = [] 109 - if not zulip_config.site: missing.append('ZULIP_SITE') 110 - if not zulip_config.email: missing.append('ZULIP_EMAIL') 111 - if not zulip_config.api_key: missing.append('ZULIP_API_KEY') 108 + if not zulip_config.site: 109 + missing.append("ZULIP_SITE") 110 + if not zulip_config.email: 111 + missing.append("ZULIP_EMAIL") 112 + if not zulip_config.api_key: 113 + missing.append("ZULIP_API_KEY") 112 114 raise ValueError( 113 - f"When using --env-config, these environment variables are required: {', '.join(missing)}" 115 + f"When using --env-config, these environment variables are " 116 + f"required: {', '.join(missing)}" 114 117 ) 115 118 else: 116 119 zulip_config = load_zuliprc_config(args.zuliprc) 117 120 # Still need server config from environment 118 121 _, server_config = load_config() 119 - 122 + 120 123 # Create and start the webhook server 121 124 server = NetdataWebhookServer(zulip_config, server_config) 122 125 server.run() 123 - 126 + 124 127 except KeyboardInterrupt: 125 128 logger.info("Shutting down webhook server") 126 129 except Exception as e: ··· 129 132 130 133 131 134 if __name__ == "__main__": 132 - main() 135 + main()
+31 -22
netdata_zulip_bot/models.py
··· 2 2 3 3 from datetime import datetime 4 4 from enum import Enum 5 - from typing import Optional, Union 6 5 7 - from pydantic import BaseModel, Field, field_validator, ConfigDict 6 + from pydantic import BaseModel, ConfigDict, Field, field_validator 8 7 9 8 10 9 class AlertSeverity(str, Enum): 11 10 """Alert severity levels.""" 11 + 12 12 WARNING = "warning" 13 13 CRITICAL = "critical" 14 14 CLEAR = "clear" ··· 16 16 17 17 class ReachabilitySeverity(str, Enum): 18 18 """Reachability severity levels.""" 19 + 19 20 INFO = "info" 20 21 CRITICAL = "critical" 21 22 22 23 23 24 class ReachabilityStatus(BaseModel): 24 25 """Reachability status information.""" 26 + 25 27 reachable: bool 26 28 text: str # "reachable" or "unreachable" 27 29 28 30 29 31 class AlertNotification(BaseModel): 30 32 """Alert notification payload from Netdata Cloud.""" 33 + 31 34 message: str 32 35 alert: str 33 36 info: str ··· 38 41 date: datetime 39 42 alert_url: str 40 43 # Additional fields from full schema 41 - Rooms: Optional[dict] = None 42 - family: Optional[str] = None 43 - class_: Optional[str] = Field(None, alias="class") # 'class' is a Python keyword 44 - duration: Optional[str] = None 45 - additional_active_critical_alerts: Optional[int] = None 46 - additional_active_warning_alerts: Optional[int] = None 44 + Rooms: dict | None = None 45 + family: str | None = None 46 + class_: str | None = Field(None, alias="class") # 'class' is a Python keyword 47 + duration: str | None = None 48 + additional_active_critical_alerts: int | None = None 49 + additional_active_warning_alerts: int | None = None 47 50 48 - @field_validator('date', mode='before') 51 + @field_validator("date", mode="before") 49 52 @classmethod 50 53 def parse_date(cls, v): 51 54 if isinstance(v, str): 52 - return datetime.fromisoformat(v.replace('Z', '+00:00')) 55 + return datetime.fromisoformat(v.replace("Z", "+00:00")) 53 56 return v 54 57 55 58 56 59 class ReachabilityNotification(BaseModel): 57 60 """Reachability notification payload from Netdata Cloud.""" 61 + 58 62 message: str 59 63 url: str 60 64 host: str ··· 64 68 65 69 class TestNotification(BaseModel): 66 70 """Test notification payload from Netdata Cloud.""" 71 + 67 72 message: str 68 73 69 74 70 75 class WebhookPayload(BaseModel): 71 76 """Union type for webhook payloads.""" 72 - 77 + 73 78 @classmethod 74 - def parse(cls, data: dict) -> Union[AlertNotification, ReachabilityNotification, TestNotification]: 79 + def parse( 80 + cls, data: dict 81 + ) -> AlertNotification | ReachabilityNotification | TestNotification: 75 82 """Parse webhook payload and determine notification type.""" 76 - if 'alert' in data and 'chart' in data: 83 + if "alert" in data and "chart" in data: 77 84 return AlertNotification(**data) 78 - elif 'status' in data and 'host' in data: 85 + elif "status" in data and "host" in data: 79 86 return ReachabilityNotification(**data) 80 - elif len(data) == 1 and 'message' in data: 87 + elif len(data) == 1 and "message" in data: 81 88 # Test notification - only has a message field 82 89 return TestNotification(**data) 83 90 else: ··· 86 93 87 94 class ZulipConfig(BaseModel): 88 95 """Zulip bot configuration.""" 89 - site: Optional[str] = None 90 - email: Optional[str] = None 91 - api_key: Optional[str] = None 96 + 97 + site: str | None = None 98 + email: str | None = None 99 + api_key: str | None = None 92 100 stream: str = "netdata-alerts" 93 - 101 + 94 102 model_config = ConfigDict(env_prefix="ZULIP_") 95 103 96 104 97 105 class ServerConfig(BaseModel): 98 106 """Server configuration.""" 107 + 99 108 host: str = "0.0.0.0" 100 109 port: int = 8080 # Default HTTP port 101 - challenge_secret: Optional[str] = None # Netdata webhook challenge secret 102 - 103 - model_config = ConfigDict(env_prefix="SERVER_") 110 + challenge_secret: str | None = None # Netdata webhook challenge secret 111 + 112 + model_config = ConfigDict(env_prefix="SERVER_")
+49 -47
netdata_zulip_bot/server.py
··· 3 3 import base64 4 4 import hashlib 5 5 import hmac 6 - from typing import Dict, Any 7 6 8 7 import structlog 9 8 import uvicorn 10 - from fastapi import FastAPI, HTTPException, Request, status, Query 11 - from fastapi.responses import JSONResponse 9 + from fastapi import FastAPI, HTTPException, Query, Request, status 12 10 13 11 from .formatter import ZulipMessageFormatter 14 - from .models import WebhookPayload, ZulipConfig, ServerConfig 12 + from .models import ServerConfig, WebhookPayload, ZulipConfig 15 13 from .zulip_client import ZulipNotifier 16 14 17 15 logger = structlog.get_logger() ··· 19 17 20 18 class NetdataWebhookServer: 21 19 """FastAPI server for handling Netdata Cloud webhooks.""" 22 - 20 + 23 21 def __init__(self, zulip_config: ZulipConfig, server_config: ServerConfig): 24 22 """Initialize the webhook server.""" 25 23 self.app = FastAPI( 26 24 title="Netdata Zulip Bot", 27 25 description="Webhook service for Netdata Cloud notifications", 28 - version="0.1.0" 26 + version="0.1.0", 29 27 ) 30 28 self.zulip_config = zulip_config 31 29 self.server_config = server_config 32 30 self.formatter = ZulipMessageFormatter() 33 - 31 + 34 32 # Initialize Zulip client 35 33 try: 36 34 self.zulip_notifier = ZulipNotifier(zulip_config) 37 35 except Exception as e: 38 36 logger.error("Failed to initialize Zulip client", error=str(e)) 39 37 raise 40 - 38 + 41 39 self._setup_routes() 42 40 self._setup_middleware() 43 - 41 + 44 42 def _setup_routes(self): 45 43 """Setup FastAPI routes.""" 46 - 44 + 47 45 @self.app.get("/health") 48 46 async def health_check(): 49 47 """Health check endpoint.""" 50 48 return {"status": "healthy", "service": "netdata-zulip-bot"} 51 - 49 + 52 50 @self.app.get("/webhook/netdata") 53 51 async def netdata_webhook_challenge(crc_token: str = Query(...)): 54 52 """Handle Netdata Cloud webhook challenge for verification.""" ··· 56 54 logger.error("Challenge secret not configured") 57 55 raise HTTPException( 58 56 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 59 - detail="Challenge secret not configured" 57 + detail="Challenge secret not configured", 60 58 ) 61 - 59 + 62 60 try: 63 61 # Create HMAC SHA-256 hash from crc_token and challenge secret 64 - token_bytes = crc_token.encode('ascii') 65 - secret_bytes = self.server_config.challenge_secret.encode('utf-8') 66 - 62 + token_bytes = crc_token.encode("ascii") 63 + secret_bytes = self.server_config.challenge_secret.encode("utf-8") 64 + 67 65 sha256_hash = hmac.new( 68 - secret_bytes, 69 - msg=token_bytes, 70 - digestmod=hashlib.sha256 66 + secret_bytes, msg=token_bytes, digestmod=hashlib.sha256 71 67 ).digest() 72 - 68 + 73 69 # Create response with base64 encoded hash 74 - response_token = 'sha256=' + base64.b64encode(sha256_hash).decode('ascii') 75 - 76 - logger.info("Responding to Netdata challenge", crc_token=crc_token[:16] + "...") 70 + response_token = "sha256=" + base64.b64encode(sha256_hash).decode( 71 + "ascii" 72 + ) 73 + 74 + logger.info( 75 + "Responding to Netdata challenge", crc_token=crc_token[:16] + "..." 76 + ) 77 77 return {"response_token": response_token} 78 - 78 + 79 79 except Exception as e: 80 80 logger.error("Failed to process challenge", error=str(e)) 81 81 raise HTTPException( 82 82 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 83 - detail="Failed to process challenge" 83 + detail="Failed to process challenge", 84 84 ) 85 - 85 + 86 86 @self.app.post("/webhook/netdata") 87 87 async def netdata_webhook(request: Request): 88 88 """Handle Netdata Cloud webhook notifications.""" ··· 90 90 # Get raw JSON data 91 91 body = await request.json() 92 92 logger.info("Received webhook", payload_keys=list(body.keys())) 93 - 93 + 94 94 # Parse and validate the payload 95 95 notification = WebhookPayload.parse(body) 96 96 logger.info("Parsed notification", type=type(notification).__name__) 97 - 97 + 98 98 # Format message for Zulip 99 99 topic, content = self.formatter.format_notification(notification) 100 100 logger.info("Formatted message", topic=topic) 101 - 101 + 102 102 # Send to Zulip 103 103 success = self.zulip_notifier.send_message(topic, content) 104 - 104 + 105 105 if success: 106 - return {"status": "success", "message": "Notification sent to Zulip"} 106 + return { 107 + "status": "success", 108 + "message": "Notification sent to Zulip", 109 + } 107 110 else: 108 111 raise HTTPException( 109 112 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 110 - detail="Failed to send notification to Zulip" 113 + detail="Failed to send notification to Zulip", 111 114 ) 112 - 115 + 113 116 except ValueError as e: 114 117 logger.error("Invalid webhook payload", error=str(e)) 115 118 raise HTTPException( 116 119 status_code=status.HTTP_400_BAD_REQUEST, 117 - detail=f"Invalid payload format: {str(e)}" 120 + detail=f"Invalid payload format: {str(e)}", 118 121 ) 119 122 except Exception as e: 120 123 logger.error("Webhook processing failed", error=str(e)) 121 124 raise HTTPException( 122 125 status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, 123 - detail="Internal server error" 126 + detail="Internal server error", 124 127 ) 125 - 128 + 126 129 def _setup_middleware(self): 127 130 """Setup middleware for logging and error handling.""" 128 - 131 + 129 132 @self.app.middleware("http") 130 133 async def log_requests(request: Request, call_next): 131 134 """Log all requests.""" ··· 134 137 "Request received", 135 138 method=request.method, 136 139 url=str(request.url), 137 - client=client_host 140 + client=client_host, 138 141 ) 139 - 142 + 140 143 try: 141 144 response = await call_next(request) 142 145 logger.info( 143 146 "Request completed", 144 147 method=request.method, 145 148 url=str(request.url), 146 - status_code=response.status_code 149 + status_code=response.status_code, 147 150 ) 148 151 return response 149 152 except Exception as e: ··· 151 154 "Request failed", 152 155 method=request.method, 153 156 url=str(request.url), 154 - error=str(e) 157 + error=str(e), 155 158 ) 156 159 raise 157 - 158 - 160 + 159 161 def run(self): 160 162 """Run the webhook server (HTTP only, TLS handled by reverse proxy).""" 161 163 try: 162 164 logger.info( 163 165 "Starting Netdata Zulip webhook server (HTTP)", 164 166 host=self.server_config.host, 165 - port=self.server_config.port 167 + port=self.server_config.port, 166 168 ) 167 - 169 + 168 170 uvicorn.run( 169 171 self.app, 170 172 host=self.server_config.host, 171 173 port=self.server_config.port, 172 174 access_log=False, # We handle logging in middleware 173 175 ) 174 - 176 + 175 177 except Exception as e: 176 178 logger.error("Failed to start server", error=str(e)) 177 - raise 179 + raise
+22 -20
netdata_zulip_bot/zulip_client.py
··· 10 10 11 11 class ZulipNotifier: 12 12 """Handles sending notifications to Zulip.""" 13 - 13 + 14 14 def __init__(self, config: ZulipConfig): 15 15 """Initialize Zulip client with configuration.""" 16 16 self.config = config ··· 19 19 email=config.email, 20 20 api_key=config.api_key, 21 21 ) 22 - 22 + 23 23 # Test connection 24 24 try: 25 25 result = self.client.get_profile() 26 - if result['result'] != 'success': 26 + if result["result"] != "success": 27 27 raise ConnectionError(f"Failed to connect to Zulip: {result}") 28 - logger.info("Connected to Zulip", user=result['email']) 28 + logger.info("Connected to Zulip", user=result["email"]) 29 29 except Exception as e: 30 30 logger.error("Failed to initialize Zulip client", error=str(e)) 31 31 raise 32 - 32 + 33 33 def send_message(self, topic: str, content: str) -> bool: 34 34 """Send a message to the configured Zulip stream. 35 - 35 + 36 36 Args: 37 37 topic: The topic within the stream 38 38 content: The message content (markdown formatted) 39 - 39 + 40 40 Returns: 41 41 True if successful, False otherwise 42 42 """ 43 43 try: 44 - result = self.client.send_message({ 45 - "type": "stream", 46 - "to": self.config.stream, 47 - "topic": topic, 48 - "content": content, 49 - }) 50 - 51 - if result['result'] == 'success': 44 + result = self.client.send_message( 45 + { 46 + "type": "stream", 47 + "to": self.config.stream, 48 + "topic": topic, 49 + "content": content, 50 + } 51 + ) 52 + 53 + if result["result"] == "success": 52 54 logger.info( 53 55 "Message sent to Zulip", 54 56 stream=self.config.stream, 55 57 topic=topic, 56 - message_id=result.get('id') 58 + message_id=result.get("id"), 57 59 ) 58 60 return True 59 61 else: ··· 61 63 "Failed to send message to Zulip", 62 64 stream=self.config.stream, 63 65 topic=topic, 64 - error=result.get('msg', 'Unknown error') 66 + error=result.get("msg", "Unknown error"), 65 67 ) 66 68 return False 67 - 69 + 68 70 except Exception as e: 69 71 logger.error( 70 72 "Exception sending message to Zulip", 71 73 stream=self.config.stream, 72 74 topic=topic, 73 - error=str(e) 75 + error=str(e), 74 76 ) 75 - return False 77 + return False
+1 -1
tests/__init__.py
··· 1 - """Tests for the Netdata Zulip Bot.""" 1 + """Tests for the Netdata Zulip Bot."""
+46 -49
tests/test_webhook.py
··· 1 1 """Tests for webhook functionality.""" 2 2 3 - import pytest 4 3 from datetime import datetime 5 - from unittest.mock import Mock, patch 6 4 7 - from netdata_zulip_bot.models import AlertNotification, ReachabilityNotification, WebhookPayload 5 + import pytest 6 + 8 7 from netdata_zulip_bot.formatter import ZulipMessageFormatter 8 + from netdata_zulip_bot.models import ( 9 + AlertNotification, 10 + ReachabilityNotification, 11 + WebhookPayload, 12 + ) 9 13 10 14 11 15 class TestWebhookPayload: 12 16 """Test webhook payload parsing.""" 13 - 17 + 14 18 def test_parse_alert_notification(self): 15 19 """Test parsing alert notification payload.""" 16 20 data = { ··· 22 26 "space": "production", 23 27 "severity": "critical", 24 28 "date": "2024-01-15T14:30:00Z", 25 - "alert_url": "https://app.netdata.cloud/spaces/abc/alerts/123" 29 + "alert_url": "https://app.netdata.cloud/spaces/abc/alerts/123", 26 30 } 27 - 31 + 28 32 notification = WebhookPayload.parse(data) 29 33 assert isinstance(notification, AlertNotification) 30 34 assert notification.severity.value == "critical" 31 35 assert notification.alert == "high_cpu_usage" 32 36 assert isinstance(notification.date, datetime) 33 - 37 + 34 38 def test_parse_reachability_notification(self): 35 39 """Test parsing reachability notification payload.""" 36 40 data = { ··· 38 42 "url": "https://app.netdata.cloud/hosts/web-server-01", 39 43 "host": "web-server-01", 40 44 "severity": "critical", 41 - "status": { 42 - "reachable": False, 43 - "text": "unreachable" 44 - } 45 + "status": {"reachable": False, "text": "unreachable"}, 45 46 } 46 - 47 + 47 48 notification = WebhookPayload.parse(data) 48 49 assert isinstance(notification, ReachabilityNotification) 49 50 assert notification.severity.value == "critical" 50 51 assert notification.host == "web-server-01" 51 52 assert not notification.status.reachable 52 - 53 + 53 54 def test_parse_unknown_payload_raises_error(self): 54 55 """Test that unknown payload types raise ValueError.""" 55 56 data = {"unknown_field": "value"} 56 - 57 + 57 58 with pytest.raises(ValueError, match="Unknown notification type"): 58 59 WebhookPayload.parse(data) 59 60 60 61 61 62 class TestZulipMessageFormatter: 62 63 """Test Zulip message formatting.""" 63 - 64 + 64 65 def setup_method(self): 65 66 """Set up test fixtures.""" 66 67 self.formatter = ZulipMessageFormatter() 67 - 68 + 68 69 def test_format_critical_alert(self): 69 70 """Test formatting critical alert notification.""" 70 71 alert = AlertNotification( ··· 72 73 alert="high_cpu_usage", 73 74 info="CPU usage exceeded 90% for 5 minutes", 74 75 chart="system.cpu", 75 - context="cpu.utilization", 76 + context="cpu.utilization", 76 77 space="production", 77 78 severity="critical", 78 79 date=datetime(2024, 1, 15, 14, 30, 0), 79 - alert_url="https://app.netdata.cloud/spaces/abc/alerts/123" 80 + alert_url="https://app.netdata.cloud/spaces/abc/alerts/123", 80 81 ) 81 - 82 + 82 83 topic, content = self.formatter.format_alert(alert) 83 - 84 + 84 85 assert topic == "critical" 85 86 assert "🔴" in content 86 87 assert "**high_cpu_usage**" in content 87 88 assert "production" in content 88 89 assert "Critical" in content 89 90 assert "2024-01-15 14:30:00 UTC" in content 90 - assert "[View Alert](https://app.netdata.cloud/spaces/abc/alerts/123)" in content 91 - 91 + assert ( 92 + "[View Alert](https://app.netdata.cloud/spaces/abc/alerts/123)" in content 93 + ) 94 + 92 95 def test_format_warning_alert(self): 93 96 """Test formatting warning alert notification.""" 94 97 alert = AlertNotification( 95 98 message="Warning: High memory usage", 96 - alert="high_memory_usage", 99 + alert="high_memory_usage", 97 100 info="Memory usage at 85%", 98 101 chart="system.ram", 99 102 context="memory.utilization", 100 103 space="staging", 101 104 severity="warning", 102 105 date=datetime(2024, 1, 15, 10, 15, 0), 103 - alert_url="https://app.netdata.cloud/spaces/def/alerts/456" 106 + alert_url="https://app.netdata.cloud/spaces/def/alerts/456", 104 107 ) 105 - 108 + 106 109 topic, content = self.formatter.format_alert(alert) 107 - 110 + 108 111 assert topic == "warning" 109 112 assert "⚠️" in content 110 113 assert "Warning" in content 111 - 114 + 112 115 def test_format_clear_alert(self): 113 116 """Test formatting clear alert notification.""" 114 117 alert = AlertNotification( 115 118 message="Alert cleared: CPU usage normal", 116 119 alert="high_cpu_usage", 117 120 info="CPU usage returned to normal levels", 118 - chart="system.cpu", 121 + chart="system.cpu", 119 122 context="cpu.utilization", 120 123 space="production", 121 124 severity="clear", 122 125 date=datetime(2024, 1, 15, 15, 0, 0), 123 - alert_url="https://app.netdata.cloud/spaces/abc/alerts/123" 126 + alert_url="https://app.netdata.cloud/spaces/abc/alerts/123", 124 127 ) 125 - 128 + 126 129 topic, content = self.formatter.format_alert(alert) 127 - 130 + 128 131 assert topic == "clear" 129 132 assert "✅" in content 130 133 assert "Clear" in content 131 - 134 + 132 135 def test_format_reachability_unreachable(self): 133 136 """Test formatting unreachable host notification.""" 134 137 notification = ReachabilityNotification( ··· 136 139 url="https://app.netdata.cloud/hosts/web-server-01", 137 140 host="web-server-01", 138 141 severity="critical", 139 - status={ 140 - "reachable": False, 141 - "text": "unreachable" 142 - } 142 + status={"reachable": False, "text": "unreachable"}, 143 143 ) 144 - 144 + 145 145 topic, content = self.formatter.format_reachability(notification) 146 - 146 + 147 147 assert topic == "reachability" 148 148 assert "🔴" in content # critical severity 149 149 assert "❌" in content # unreachable status 150 150 assert "web-server-01" in content 151 151 assert "Unreachable" in content 152 152 assert "[View Host](https://app.netdata.cloud/hosts/web-server-01)" in content 153 - 153 + 154 154 def test_format_reachability_reachable(self): 155 155 """Test formatting reachable host notification.""" 156 156 notification = ReachabilityNotification( 157 157 message="Host web-server-01 is reachable again", 158 - url="https://app.netdata.cloud/hosts/web-server-01", 158 + url="https://app.netdata.cloud/hosts/web-server-01", 159 159 host="web-server-01", 160 160 severity="info", 161 - status={ 162 - "reachable": True, 163 - "text": "reachable" 164 - } 161 + status={"reachable": True, "text": "reachable"}, 165 162 ) 166 - 163 + 167 164 topic, content = self.formatter.format_reachability(notification) 168 - 165 + 169 166 assert topic == "reachability" 170 - assert "ℹ️" in content # info severity 171 - assert "✅" in content # reachable status 172 - assert "Reachable" in content 167 + assert "ℹ️" in content # info severity 168 + assert "✅" in content # reachable status 169 + assert "Reachable" in content
-151
uv.lock
··· 3 3 requires-python = ">=3.11" 4 4 5 5 [[package]] 6 - name = "acme" 7 - version = "4.2.0" 8 - source = { registry = "https://pypi.org/simple" } 9 - dependencies = [ 10 - { name = "cryptography" }, 11 - { name = "josepy" }, 12 - { name = "pyopenssl" }, 13 - { name = "pyrfc3339" }, 14 - { name = "requests" }, 15 - ] 16 - sdist = { url = "https://files.pythonhosted.org/packages/48/df/d006c4920fd04b843c21698bd038968cb9caa3315608f55abde0f8e4ad6b/acme-4.2.0.tar.gz", hash = "sha256:0df68c0e1acb3824a2100013f8cd51bda2e1a56aa23447449d14c942959f0c41", size = 96820, upload-time = "2025-08-05T19:19:08.86Z" } 17 - wheels = [ 18 - { url = "https://files.pythonhosted.org/packages/86/26/9ff889b5d762616bf92ecbeb1ab93faddfd7bf6068146340359e9a6beb43/acme-4.2.0-py3-none-any.whl", hash = "sha256:6292011bbfa5f966521b2fb9469982c24ff4c58e240985f14564ccf35372e79a", size = 101573, upload-time = "2025-08-05T19:18:45.266Z" }, 19 - ] 20 - 21 - [[package]] 22 6 name = "annotated-types" 23 7 version = "0.7.0" 24 8 source = { registry = "https://pypi.org/simple" } ··· 79 63 ] 80 64 81 65 [[package]] 82 - name = "cffi" 83 - version = "1.17.1" 84 - source = { registry = "https://pypi.org/simple" } 85 - dependencies = [ 86 - { name = "pycparser" }, 87 - ] 88 - sdist = { url = "https://files.pythonhosted.org/packages/fc/97/c783634659c2920c3fc70419e3af40972dbaf758daa229a7d6ea6135c90d/cffi-1.17.1.tar.gz", hash = "sha256:1c39c6016c32bc48dd54561950ebd6836e1670f2ae46128f67cf49e789c52824", size = 516621, upload-time = "2024-09-04T20:45:21.852Z" } 89 - wheels = [ 90 - { url = "https://files.pythonhosted.org/packages/6b/f4/927e3a8899e52a27fa57a48607ff7dc91a9ebe97399b357b85a0c7892e00/cffi-1.17.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a45e3c6913c5b87b3ff120dcdc03f6131fa0065027d0ed7ee6190736a74cd401", size = 182264, upload-time = "2024-09-04T20:43:51.124Z" }, 91 - { url = "https://files.pythonhosted.org/packages/6c/f5/6c3a8efe5f503175aaddcbea6ad0d2c96dad6f5abb205750d1b3df44ef29/cffi-1.17.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:30c5e0cb5ae493c04c8b42916e52ca38079f1b235c2f8ae5f4527b963c401caf", size = 178651, upload-time = "2024-09-04T20:43:52.872Z" }, 92 - { url = "https://files.pythonhosted.org/packages/94/dd/a3f0118e688d1b1a57553da23b16bdade96d2f9bcda4d32e7d2838047ff7/cffi-1.17.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f75c7ab1f9e4aca5414ed4d8e5c0e303a34f4421f8a0d47a4d019ceff0ab6af4", size = 445259, upload-time = "2024-09-04T20:43:56.123Z" }, 93 - { url = "https://files.pythonhosted.org/packages/2e/ea/70ce63780f096e16ce8588efe039d3c4f91deb1dc01e9c73a287939c79a6/cffi-1.17.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1ed2dd2972641495a3ec98445e09766f077aee98a1c896dcb4ad0d303628e41", size = 469200, upload-time = "2024-09-04T20:43:57.891Z" }, 94 - { url = "https://files.pythonhosted.org/packages/1c/a0/a4fa9f4f781bda074c3ddd57a572b060fa0df7655d2a4247bbe277200146/cffi-1.17.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:46bf43160c1a35f7ec506d254e5c890f3c03648a4dbac12d624e4490a7046cd1", size = 477235, upload-time = "2024-09-04T20:44:00.18Z" }, 95 - { url = "https://files.pythonhosted.org/packages/62/12/ce8710b5b8affbcdd5c6e367217c242524ad17a02fe5beec3ee339f69f85/cffi-1.17.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a24ed04c8ffd54b0729c07cee15a81d964e6fee0e3d4d342a27b020d22959dc6", size = 459721, upload-time = "2024-09-04T20:44:01.585Z" }, 96 - { url = "https://files.pythonhosted.org/packages/ff/6b/d45873c5e0242196f042d555526f92aa9e0c32355a1be1ff8c27f077fd37/cffi-1.17.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:610faea79c43e44c71e1ec53a554553fa22321b65fae24889706c0a84d4ad86d", size = 467242, upload-time = "2024-09-04T20:44:03.467Z" }, 97 - { url = "https://files.pythonhosted.org/packages/1a/52/d9a0e523a572fbccf2955f5abe883cfa8bcc570d7faeee06336fbd50c9fc/cffi-1.17.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:a9b15d491f3ad5d692e11f6b71f7857e7835eb677955c00cc0aefcd0669adaf6", size = 477999, upload-time = "2024-09-04T20:44:05.023Z" }, 98 - { url = "https://files.pythonhosted.org/packages/44/74/f2a2460684a1a2d00ca799ad880d54652841a780c4c97b87754f660c7603/cffi-1.17.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:de2ea4b5833625383e464549fec1bc395c1bdeeb5f25c4a3a82b5a8c756ec22f", size = 454242, upload-time = "2024-09-04T20:44:06.444Z" }, 99 - { url = "https://files.pythonhosted.org/packages/f8/4a/34599cac7dfcd888ff54e801afe06a19c17787dfd94495ab0c8d35fe99fb/cffi-1.17.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:fc48c783f9c87e60831201f2cce7f3b2e4846bf4d8728eabe54d60700b318a0b", size = 478604, upload-time = "2024-09-04T20:44:08.206Z" }, 100 - { url = "https://files.pythonhosted.org/packages/34/33/e1b8a1ba29025adbdcda5fb3a36f94c03d771c1b7b12f726ff7fef2ebe36/cffi-1.17.1-cp311-cp311-win32.whl", hash = "sha256:85a950a4ac9c359340d5963966e3e0a94a676bd6245a4b55bc43949eee26a655", size = 171727, upload-time = "2024-09-04T20:44:09.481Z" }, 101 - { url = "https://files.pythonhosted.org/packages/3d/97/50228be003bb2802627d28ec0627837ac0bf35c90cf769812056f235b2d1/cffi-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:caaf0640ef5f5517f49bc275eca1406b0ffa6aa184892812030f04c2abf589a0", size = 181400, upload-time = "2024-09-04T20:44:10.873Z" }, 102 - { url = "https://files.pythonhosted.org/packages/5a/84/e94227139ee5fb4d600a7a4927f322e1d4aea6fdc50bd3fca8493caba23f/cffi-1.17.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:805b4371bf7197c329fcb3ead37e710d1bca9da5d583f5073b799d5c5bd1eee4", size = 183178, upload-time = "2024-09-04T20:44:12.232Z" }, 103 - { url = "https://files.pythonhosted.org/packages/da/ee/fb72c2b48656111c4ef27f0f91da355e130a923473bf5ee75c5643d00cca/cffi-1.17.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:733e99bc2df47476e3848417c5a4540522f234dfd4ef3ab7fafdf555b082ec0c", size = 178840, upload-time = "2024-09-04T20:44:13.739Z" }, 104 - { url = "https://files.pythonhosted.org/packages/cc/b6/db007700f67d151abadf508cbfd6a1884f57eab90b1bb985c4c8c02b0f28/cffi-1.17.1-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1257bdabf294dceb59f5e70c64a3e2f462c30c7ad68092d01bbbfb1c16b1ba36", size = 454803, upload-time = "2024-09-04T20:44:15.231Z" }, 105 - { url = "https://files.pythonhosted.org/packages/1a/df/f8d151540d8c200eb1c6fba8cd0dfd40904f1b0682ea705c36e6c2e97ab3/cffi-1.17.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da95af8214998d77a98cc14e3a3bd00aa191526343078b530ceb0bd710fb48a5", size = 478850, upload-time = "2024-09-04T20:44:17.188Z" }, 106 - { url = "https://files.pythonhosted.org/packages/28/c0/b31116332a547fd2677ae5b78a2ef662dfc8023d67f41b2a83f7c2aa78b1/cffi-1.17.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d63afe322132c194cf832bfec0dc69a99fb9bb6bbd550f161a49e9e855cc78ff", size = 485729, upload-time = "2024-09-04T20:44:18.688Z" }, 107 - { url = "https://files.pythonhosted.org/packages/91/2b/9a1ddfa5c7f13cab007a2c9cc295b70fbbda7cb10a286aa6810338e60ea1/cffi-1.17.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f79fc4fc25f1c8698ff97788206bb3c2598949bfe0fef03d299eb1b5356ada99", size = 471256, upload-time = "2024-09-04T20:44:20.248Z" }, 108 - { url = "https://files.pythonhosted.org/packages/b2/d5/da47df7004cb17e4955df6a43d14b3b4ae77737dff8bf7f8f333196717bf/cffi-1.17.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b62ce867176a75d03a665bad002af8e6d54644fad99a3c70905c543130e39d93", size = 479424, upload-time = "2024-09-04T20:44:21.673Z" }, 109 - { url = "https://files.pythonhosted.org/packages/0b/ac/2a28bcf513e93a219c8a4e8e125534f4f6db03e3179ba1c45e949b76212c/cffi-1.17.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:386c8bf53c502fff58903061338ce4f4950cbdcb23e2902d86c0f722b786bbe3", size = 484568, upload-time = "2024-09-04T20:44:23.245Z" }, 110 - { url = "https://files.pythonhosted.org/packages/d4/38/ca8a4f639065f14ae0f1d9751e70447a261f1a30fa7547a828ae08142465/cffi-1.17.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:4ceb10419a9adf4460ea14cfd6bc43d08701f0835e979bf821052f1805850fe8", size = 488736, upload-time = "2024-09-04T20:44:24.757Z" }, 111 - { url = "https://files.pythonhosted.org/packages/86/c5/28b2d6f799ec0bdecf44dced2ec5ed43e0eb63097b0f58c293583b406582/cffi-1.17.1-cp312-cp312-win32.whl", hash = "sha256:a08d7e755f8ed21095a310a693525137cfe756ce62d066e53f502a83dc550f65", size = 172448, upload-time = "2024-09-04T20:44:26.208Z" }, 112 - { url = "https://files.pythonhosted.org/packages/50/b9/db34c4755a7bd1cb2d1603ac3863f22bcecbd1ba29e5ee841a4bc510b294/cffi-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:51392eae71afec0d0c8fb1a53b204dbb3bcabcb3c9b807eedf3e1e6ccf2de903", size = 181976, upload-time = "2024-09-04T20:44:27.578Z" }, 113 - { url = "https://files.pythonhosted.org/packages/8d/f8/dd6c246b148639254dad4d6803eb6a54e8c85c6e11ec9df2cffa87571dbe/cffi-1.17.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f3a2b4222ce6b60e2e8b337bb9596923045681d71e5a082783484d845390938e", size = 182989, upload-time = "2024-09-04T20:44:28.956Z" }, 114 - { url = "https://files.pythonhosted.org/packages/8b/f1/672d303ddf17c24fc83afd712316fda78dc6fce1cd53011b839483e1ecc8/cffi-1.17.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0984a4925a435b1da406122d4d7968dd861c1385afe3b45ba82b750f229811e2", size = 178802, upload-time = "2024-09-04T20:44:30.289Z" }, 115 - { url = "https://files.pythonhosted.org/packages/0e/2d/eab2e858a91fdff70533cab61dcff4a1f55ec60425832ddfdc9cd36bc8af/cffi-1.17.1-cp313-cp313-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d01b12eeeb4427d3110de311e1774046ad344f5b1a7403101878976ecd7a10f3", size = 454792, upload-time = "2024-09-04T20:44:32.01Z" }, 116 - { url = "https://files.pythonhosted.org/packages/75/b2/fbaec7c4455c604e29388d55599b99ebcc250a60050610fadde58932b7ee/cffi-1.17.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:706510fe141c86a69c8ddc029c7910003a17353970cff3b904ff0686a5927683", size = 478893, upload-time = "2024-09-04T20:44:33.606Z" }, 117 - { url = "https://files.pythonhosted.org/packages/4f/b7/6e4a2162178bf1935c336d4da8a9352cccab4d3a5d7914065490f08c0690/cffi-1.17.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:de55b766c7aa2e2a3092c51e0483d700341182f08e67c63630d5b6f200bb28e5", size = 485810, upload-time = "2024-09-04T20:44:35.191Z" }, 118 - { url = "https://files.pythonhosted.org/packages/c7/8a/1d0e4a9c26e54746dc08c2c6c037889124d4f59dffd853a659fa545f1b40/cffi-1.17.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c59d6e989d07460165cc5ad3c61f9fd8f1b4796eacbd81cee78957842b834af4", size = 471200, upload-time = "2024-09-04T20:44:36.743Z" }, 119 - { url = "https://files.pythonhosted.org/packages/26/9f/1aab65a6c0db35f43c4d1b4f580e8df53914310afc10ae0397d29d697af4/cffi-1.17.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd398dbc6773384a17fe0d3e7eeb8d1a21c2200473ee6806bb5e6a8e62bb73dd", size = 479447, upload-time = "2024-09-04T20:44:38.492Z" }, 120 - { url = "https://files.pythonhosted.org/packages/5f/e4/fb8b3dd8dc0e98edf1135ff067ae070bb32ef9d509d6cb0f538cd6f7483f/cffi-1.17.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:3edc8d958eb099c634dace3c7e16560ae474aa3803a5df240542b305d14e14ed", size = 484358, upload-time = "2024-09-04T20:44:40.046Z" }, 121 - { url = "https://files.pythonhosted.org/packages/f1/47/d7145bf2dc04684935d57d67dff9d6d795b2ba2796806bb109864be3a151/cffi-1.17.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:72e72408cad3d5419375fc87d289076ee319835bdfa2caad331e377589aebba9", size = 488469, upload-time = "2024-09-04T20:44:41.616Z" }, 122 - { url = "https://files.pythonhosted.org/packages/bf/ee/f94057fa6426481d663b88637a9a10e859e492c73d0384514a17d78ee205/cffi-1.17.1-cp313-cp313-win32.whl", hash = "sha256:e03eab0a8677fa80d646b5ddece1cbeaf556c313dcfac435ba11f107ba117b5d", size = 172475, upload-time = "2024-09-04T20:44:43.733Z" }, 123 - { url = "https://files.pythonhosted.org/packages/7c/fc/6a8cb64e5f0324877d503c854da15d76c1e50eb722e320b15345c4d0c6de/cffi-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:f6a16c31041f09ead72d69f583767292f750d24913dadacf5756b966aacb3f1a", size = 182009, upload-time = "2024-09-04T20:44:45.309Z" }, 124 - ] 125 - 126 - [[package]] 127 66 name = "charset-normalizer" 128 67 version = "3.4.3" 129 68 source = { registry = "https://pypi.org/simple" } ··· 198 137 ] 199 138 200 139 [[package]] 201 - name = "cryptography" 202 - version = "45.0.6" 203 - source = { registry = "https://pypi.org/simple" } 204 - dependencies = [ 205 - { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, 206 - ] 207 - sdist = { url = "https://files.pythonhosted.org/packages/d6/0d/d13399c94234ee8f3df384819dc67e0c5ce215fb751d567a55a1f4b028c7/cryptography-45.0.6.tar.gz", hash = "sha256:5c966c732cf6e4a276ce83b6e4c729edda2df6929083a952cc7da973c539c719", size = 744949, upload-time = "2025-08-05T23:59:27.93Z" } 208 - wheels = [ 209 - { url = "https://files.pythonhosted.org/packages/8c/29/2793d178d0eda1ca4a09a7c4e09a5185e75738cc6d526433e8663b460ea6/cryptography-45.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:048e7ad9e08cf4c0ab07ff7f36cc3115924e22e2266e034450a890d9e312dd74", size = 7042702, upload-time = "2025-08-05T23:58:23.464Z" }, 210 - { url = "https://files.pythonhosted.org/packages/b3/b6/cabd07410f222f32c8d55486c464f432808abaa1f12af9afcbe8f2f19030/cryptography-45.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:44647c5d796f5fc042bbc6d61307d04bf29bccb74d188f18051b635f20a9c75f", size = 4206483, upload-time = "2025-08-05T23:58:27.132Z" }, 211 - { url = "https://files.pythonhosted.org/packages/8b/9e/f9c7d36a38b1cfeb1cc74849aabe9bf817990f7603ff6eb485e0d70e0b27/cryptography-45.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e40b80ecf35ec265c452eea0ba94c9587ca763e739b8e559c128d23bff7ebbbf", size = 4429679, upload-time = "2025-08-05T23:58:29.152Z" }, 212 - { url = "https://files.pythonhosted.org/packages/9c/2a/4434c17eb32ef30b254b9e8b9830cee4e516f08b47fdd291c5b1255b8101/cryptography-45.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:00e8724bdad672d75e6f069b27970883179bd472cd24a63f6e620ca7e41cc0c5", size = 4210553, upload-time = "2025-08-05T23:58:30.596Z" }, 213 - { url = "https://files.pythonhosted.org/packages/ef/1d/09a5df8e0c4b7970f5d1f3aff1b640df6d4be28a64cae970d56c6cf1c772/cryptography-45.0.6-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a3085d1b319d35296176af31c90338eeb2ddac8104661df79f80e1d9787b8b2", size = 3894499, upload-time = "2025-08-05T23:58:32.03Z" }, 214 - { url = "https://files.pythonhosted.org/packages/79/62/120842ab20d9150a9d3a6bdc07fe2870384e82f5266d41c53b08a3a96b34/cryptography-45.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1b7fa6a1c1188c7ee32e47590d16a5a0646270921f8020efc9a511648e1b2e08", size = 4458484, upload-time = "2025-08-05T23:58:33.526Z" }, 215 - { url = "https://files.pythonhosted.org/packages/fd/80/1bc3634d45ddfed0871bfba52cf8f1ad724761662a0c792b97a951fb1b30/cryptography-45.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:275ba5cc0d9e320cd70f8e7b96d9e59903c815ca579ab96c1e37278d231fc402", size = 4210281, upload-time = "2025-08-05T23:58:35.445Z" }, 216 - { url = "https://files.pythonhosted.org/packages/7d/fe/ffb12c2d83d0ee625f124880a1f023b5878f79da92e64c37962bbbe35f3f/cryptography-45.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:f4028f29a9f38a2025abedb2e409973709c660d44319c61762202206ed577c42", size = 4456890, upload-time = "2025-08-05T23:58:36.923Z" }, 217 - { url = "https://files.pythonhosted.org/packages/8c/8e/b3f3fe0dc82c77a0deb5f493b23311e09193f2268b77196ec0f7a36e3f3e/cryptography-45.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ee411a1b977f40bd075392c80c10b58025ee5c6b47a822a33c1198598a7a5f05", size = 4333247, upload-time = "2025-08-05T23:58:38.781Z" }, 218 - { url = "https://files.pythonhosted.org/packages/b3/a6/c3ef2ab9e334da27a1d7b56af4a2417d77e7806b2e0f90d6267ce120d2e4/cryptography-45.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:e2a21a8eda2d86bb604934b6b37691585bd095c1f788530c1fcefc53a82b3453", size = 4565045, upload-time = "2025-08-05T23:58:40.415Z" }, 219 - { url = "https://files.pythonhosted.org/packages/31/c3/77722446b13fa71dddd820a5faab4ce6db49e7e0bf8312ef4192a3f78e2f/cryptography-45.0.6-cp311-abi3-win32.whl", hash = "sha256:d063341378d7ee9c91f9d23b431a3502fc8bfacd54ef0a27baa72a0843b29159", size = 2928923, upload-time = "2025-08-05T23:58:41.919Z" }, 220 - { url = "https://files.pythonhosted.org/packages/38/63/a025c3225188a811b82932a4dcc8457a26c3729d81578ccecbcce2cb784e/cryptography-45.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:833dc32dfc1e39b7376a87b9a6a4288a10aae234631268486558920029b086ec", size = 3403805, upload-time = "2025-08-05T23:58:43.792Z" }, 221 - { url = "https://files.pythonhosted.org/packages/5b/af/bcfbea93a30809f126d51c074ee0fac5bd9d57d068edf56c2a73abedbea4/cryptography-45.0.6-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:3436128a60a5e5490603ab2adbabc8763613f638513ffa7d311c900a8349a2a0", size = 7020111, upload-time = "2025-08-05T23:58:45.316Z" }, 222 - { url = "https://files.pythonhosted.org/packages/98/c6/ea5173689e014f1a8470899cd5beeb358e22bb3cf5a876060f9d1ca78af4/cryptography-45.0.6-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0d9ef57b6768d9fa58e92f4947cea96ade1233c0e236db22ba44748ffedca394", size = 4198169, upload-time = "2025-08-05T23:58:47.121Z" }, 223 - { url = "https://files.pythonhosted.org/packages/ba/73/b12995edc0c7e2311ffb57ebd3b351f6b268fed37d93bfc6f9856e01c473/cryptography-45.0.6-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea3c42f2016a5bbf71825537c2ad753f2870191134933196bee408aac397b3d9", size = 4421273, upload-time = "2025-08-05T23:58:48.557Z" }, 224 - { url = "https://files.pythonhosted.org/packages/f7/6e/286894f6f71926bc0da67408c853dd9ba953f662dcb70993a59fd499f111/cryptography-45.0.6-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:20ae4906a13716139d6d762ceb3e0e7e110f7955f3bc3876e3a07f5daadec5f3", size = 4199211, upload-time = "2025-08-05T23:58:50.139Z" }, 225 - { url = "https://files.pythonhosted.org/packages/de/34/a7f55e39b9623c5cb571d77a6a90387fe557908ffc44f6872f26ca8ae270/cryptography-45.0.6-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dac5ec199038b8e131365e2324c03d20e97fe214af051d20c49db129844e8b3", size = 3883732, upload-time = "2025-08-05T23:58:52.253Z" }, 226 - { url = "https://files.pythonhosted.org/packages/f9/b9/c6d32edbcba0cd9f5df90f29ed46a65c4631c4fbe11187feb9169c6ff506/cryptography-45.0.6-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:18f878a34b90d688982e43f4b700408b478102dd58b3e39de21b5ebf6509c301", size = 4450655, upload-time = "2025-08-05T23:58:53.848Z" }, 227 - { url = "https://files.pythonhosted.org/packages/77/2d/09b097adfdee0227cfd4c699b3375a842080f065bab9014248933497c3f9/cryptography-45.0.6-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:5bd6020c80c5b2b2242d6c48487d7b85700f5e0038e67b29d706f98440d66eb5", size = 4198956, upload-time = "2025-08-05T23:58:55.209Z" }, 228 - { url = "https://files.pythonhosted.org/packages/55/66/061ec6689207d54effdff535bbdf85cc380d32dd5377173085812565cf38/cryptography-45.0.6-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:eccddbd986e43014263eda489abbddfbc287af5cddfd690477993dbb31e31016", size = 4449859, upload-time = "2025-08-05T23:58:56.639Z" }, 229 - { url = "https://files.pythonhosted.org/packages/41/ff/e7d5a2ad2d035e5a2af116e1a3adb4d8fcd0be92a18032917a089c6e5028/cryptography-45.0.6-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:550ae02148206beb722cfe4ef0933f9352bab26b087af00e48fdfb9ade35c5b3", size = 4320254, upload-time = "2025-08-05T23:58:58.833Z" }, 230 - { url = "https://files.pythonhosted.org/packages/82/27/092d311af22095d288f4db89fcaebadfb2f28944f3d790a4cf51fe5ddaeb/cryptography-45.0.6-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5b64e668fc3528e77efa51ca70fadcd6610e8ab231e3e06ae2bab3b31c2b8ed9", size = 4554815, upload-time = "2025-08-05T23:59:00.283Z" }, 231 - { url = "https://files.pythonhosted.org/packages/7e/01/aa2f4940262d588a8fdf4edabe4cda45854d00ebc6eaac12568b3a491a16/cryptography-45.0.6-cp37-abi3-win32.whl", hash = "sha256:780c40fb751c7d2b0c6786ceee6b6f871e86e8718a8ff4bc35073ac353c7cd02", size = 2912147, upload-time = "2025-08-05T23:59:01.716Z" }, 232 - { url = "https://files.pythonhosted.org/packages/0a/bc/16e0276078c2de3ceef6b5a34b965f4436215efac45313df90d55f0ba2d2/cryptography-45.0.6-cp37-abi3-win_amd64.whl", hash = "sha256:20d15aed3ee522faac1a39fbfdfee25d17b1284bafd808e1640a74846d7c4d1b", size = 3390459, upload-time = "2025-08-05T23:59:03.358Z" }, 233 - { url = "https://files.pythonhosted.org/packages/61/69/c252de4ec047ba2f567ecb53149410219577d408c2aea9c989acae7eafce/cryptography-45.0.6-pp311-pypy311_pp73-macosx_10_9_x86_64.whl", hash = "sha256:fc022c1fa5acff6def2fc6d7819bbbd31ccddfe67d075331a65d9cfb28a20983", size = 3584669, upload-time = "2025-08-05T23:59:15.431Z" }, 234 - { url = "https://files.pythonhosted.org/packages/e3/fe/deea71e9f310a31fe0a6bfee670955152128d309ea2d1c79e2a5ae0f0401/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:3de77e4df42ac8d4e4d6cdb342d989803ad37707cf8f3fbf7b088c9cbdd46427", size = 4153022, upload-time = "2025-08-05T23:59:16.954Z" }, 235 - { url = "https://files.pythonhosted.org/packages/60/45/a77452f5e49cb580feedba6606d66ae7b82c128947aa754533b3d1bd44b0/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:599c8d7df950aa68baa7e98f7b73f4f414c9f02d0e8104a30c0182a07732638b", size = 4386802, upload-time = "2025-08-05T23:59:18.55Z" }, 236 - { url = "https://files.pythonhosted.org/packages/a3/b9/a2f747d2acd5e3075fdf5c145c7c3568895daaa38b3b0c960ef830db6cdc/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:31a2b9a10530a1cb04ffd6aa1cd4d3be9ed49f7d77a4dafe198f3b382f41545c", size = 4152706, upload-time = "2025-08-05T23:59:20.044Z" }, 237 - { url = "https://files.pythonhosted.org/packages/81/ec/381b3e8d0685a3f3f304a382aa3dfce36af2d76467da0fd4bb21ddccc7b2/cryptography-45.0.6-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:e5b3dda1b00fb41da3af4c5ef3f922a200e33ee5ba0f0bc9ecf0b0c173958385", size = 4386740, upload-time = "2025-08-05T23:59:21.525Z" }, 238 - { url = "https://files.pythonhosted.org/packages/0a/76/cf8d69da8d0b5ecb0db406f24a63a3f69ba5e791a11b782aeeefef27ccbb/cryptography-45.0.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:629127cfdcdc6806dfe234734d7cb8ac54edaf572148274fa377a7d3405b0043", size = 3331874, upload-time = "2025-08-05T23:59:23.017Z" }, 239 - ] 240 - 241 - [[package]] 242 140 name = "distro" 243 141 version = "1.9.0" 244 142 source = { registry = "https://pypi.org/simple" } ··· 343 241 sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793, upload-time = "2025-03-19T20:09:59.721Z" } 344 242 wheels = [ 345 243 { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050, upload-time = "2025-03-19T20:10:01.071Z" }, 346 - ] 347 - 348 - [[package]] 349 - name = "josepy" 350 - version = "2.1.0" 351 - source = { registry = "https://pypi.org/simple" } 352 - dependencies = [ 353 - { name = "cryptography" }, 354 - ] 355 - sdist = { url = "https://files.pythonhosted.org/packages/9d/19/4ebe24c42c341c5868dff072b78d503fc1b0725d88ea619d2db68f5624a9/josepy-2.1.0.tar.gz", hash = "sha256:9beafbaa107ec7128e6c21d86b2bc2aea2f590158e50aca972dca3753046091f", size = 56189, upload-time = "2025-07-08T17:20:54.98Z" } 356 - wheels = [ 357 - { url = "https://files.pythonhosted.org/packages/47/0e/e248f059986e376b87e546cd840d244370b9f7ef2b336456f0c2cfb2fef0/josepy-2.1.0-py3-none-any.whl", hash = "sha256:0eadf09b96821bdae9a8b14145425cb9fe0bbee64c6fdfce3ccd4ceb7d7efbbd", size = 29065, upload-time = "2025-07-08T17:20:53.504Z" }, 358 244 ] 359 245 360 246 [[package]] ··· 371 257 version = "0.1.0" 372 258 source = { editable = "." } 373 259 dependencies = [ 374 - { name = "acme" }, 375 - { name = "cryptography" }, 376 260 { name = "fastapi" }, 377 - { name = "josepy" }, 378 261 { name = "pydantic" }, 379 262 { name = "python-dotenv" }, 380 263 { name = "python-multipart" }, ··· 401 284 402 285 [package.metadata] 403 286 requires-dist = [ 404 - { name = "acme", specifier = ">=2.8.0" }, 405 287 { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, 406 - { name = "cryptography", specifier = ">=41.0.0" }, 407 288 { name = "fastapi", specifier = ">=0.104.0" }, 408 289 { name = "httpx", marker = "extra == 'dev'", specifier = ">=0.25.0" }, 409 - { name = "josepy", specifier = ">=1.14.0" }, 410 290 { name = "pydantic", specifier = ">=2.5.0" }, 411 291 { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.4.0" }, 412 292 { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, ··· 463 343 ] 464 344 465 345 [[package]] 466 - name = "pycparser" 467 - version = "2.22" 468 - source = { registry = "https://pypi.org/simple" } 469 - sdist = { url = "https://files.pythonhosted.org/packages/1d/b2/31537cf4b1ca988837256c910a668b553fceb8f069bedc4b1c826024b52c/pycparser-2.22.tar.gz", hash = "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6", size = 172736, upload-time = "2024-03-30T13:22:22.564Z" } 470 - wheels = [ 471 - { url = "https://files.pythonhosted.org/packages/13/a3/a812df4e2dd5696d1f351d58b8fe16a405b234ad2886a0dab9183fb78109/pycparser-2.22-py3-none-any.whl", hash = "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc", size = 117552, upload-time = "2024-03-30T13:22:20.476Z" }, 472 - ] 473 - 474 - [[package]] 475 346 name = "pydantic" 476 347 version = "2.11.7" 477 348 source = { registry = "https://pypi.org/simple" } ··· 558 429 sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } 559 430 wheels = [ 560 431 { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, 561 - ] 562 - 563 - [[package]] 564 - name = "pyopenssl" 565 - version = "25.1.0" 566 - source = { registry = "https://pypi.org/simple" } 567 - dependencies = [ 568 - { name = "cryptography" }, 569 - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, 570 - ] 571 - sdist = { url = "https://files.pythonhosted.org/packages/04/8c/cd89ad05804f8e3c17dea8f178c3f40eeab5694c30e0c9f5bcd49f576fc3/pyopenssl-25.1.0.tar.gz", hash = "sha256:8d031884482e0c67ee92bf9a4d8cceb08d92aba7136432ffb0703c5280fc205b", size = 179937, upload-time = "2025-05-17T16:28:31.31Z" } 572 - wheels = [ 573 - { url = "https://files.pythonhosted.org/packages/80/28/2659c02301b9500751f8d42f9a6632e1508aa5120de5e43042b8b30f8d5d/pyopenssl-25.1.0-py3-none-any.whl", hash = "sha256:2b11f239acc47ac2e5aca04fd7fa829800aeee22a2eb30d744572a157bd8a1ab", size = 56771, upload-time = "2025-05-17T16:28:29.197Z" }, 574 - ] 575 - 576 - [[package]] 577 - name = "pyrfc3339" 578 - version = "2.0.1" 579 - source = { registry = "https://pypi.org/simple" } 580 - sdist = { url = "https://files.pythonhosted.org/packages/f0/d2/6587e8ec3951cbd97c56333d11e0f8a3a4cb64c0d6ed101882b7b31c431f/pyrfc3339-2.0.1.tar.gz", hash = "sha256:e47843379ea35c1296c3b6c67a948a1a490ae0584edfcbdea0eaffb5dd29960b", size = 4573, upload-time = "2024-11-04T01:57:09.959Z" } 581 - wheels = [ 582 - { url = "https://files.pythonhosted.org/packages/dd/ba/d778be0f6d8d583307b47ba481c95f88a59d5c6dfd5d136bc56656d1d17f/pyRFC3339-2.0.1-py3-none-any.whl", hash = "sha256:30b70a366acac3df7386b558c21af871522560ed7f3f73cf344b8c2cbb8b0c9d", size = 5777, upload-time = "2024-11-04T01:57:08.185Z" }, 583 432 ] 584 433 585 434 [[package]]