for assorted things

feat: add flight-notifier web app MVP

- FastAPI backend with flight checking and DM endpoints
- Browser-based UI with geolocation support
- Advanced filtering capabilities (aircraft type, airline, airports)
- Filter presets for military, commercial, and GA flights
- Visual flight categorization with badges
- Auto-refresh and browser notifications
- Ready for ngrok deployment

Next: Add subscription management and OAuth

+2286 -25
+129 -25
dm-me-when-a-flight-passes-over
··· 1 1 #!/usr/bin/env -S uv run --script --quiet 2 2 # /// script 3 3 # requires-python = ">=3.12" 4 - # dependencies = ["atproto", "pydantic-settings", "geopy", "httpx"] 4 + # dependencies = ["atproto", "pydantic-settings", "geopy", "httpx", "jinja2"] 5 5 # /// 6 6 """ 7 7 Monitor flights passing overhead and send BlueSky DMs. ··· 128 128 import httpx 129 129 from atproto import Client 130 130 from geopy import distance 131 + from jinja2 import Template 131 132 from pydantic import BaseModel, Field 132 133 from pydantic_settings import BaseSettings, SettingsConfigDict 133 134 ··· 143 144 144 145 145 146 class Subscriber(BaseModel): 146 - """Subscriber with location information""" 147 + """Subscriber with location and notification preferences""" 147 148 148 149 handle: str 149 150 latitude: float 150 151 longitude: float 151 152 radius_miles: float = 5.0 153 + filters: dict[str, list[str]] = Field(default_factory=dict) 154 + message_template: str | None = None 152 155 153 156 154 157 class Flight(BaseModel): ··· 238 241 return [] 239 242 240 243 241 - def format_flight_info(flight: Flight) -> str: 242 - """Format flight information for a DM.""" 243 - parts = ["✈️ Flight passing overhead!\n"] 244 + DEFAULT_MESSAGE_TEMPLATE = """✈️ Flight passing overhead! 245 + 246 + Flight: {{ flight.callsign or 'Unknown' }} 247 + Distance: {{ flight.distance_miles }} miles 248 + {%- if flight.altitude %} 249 + Altitude: {{ "{:,.0f}".format(flight.altitude) }} ft 250 + {%- endif %} 251 + {%- if flight.ground_speed %} 252 + Speed: {{ "{:.0f}".format(flight.ground_speed) }} kts 253 + {%- endif %} 254 + {%- if flight.heading %} 255 + Heading: {{ "{:.0f}".format(flight.heading) }}° 256 + {%- endif %} 257 + {%- if flight.aircraft_type %} 258 + Aircraft: {{ flight.aircraft_type }} 259 + {%- endif %} 260 + {%- if flight.origin or flight.destination %} 261 + Route: {{ flight.origin or '???' }} → {{ flight.destination or '???' }} 262 + {%- endif %} 263 + 264 + Time: {{ timestamp }}""" 265 + 266 + 267 + def format_flight_info(flight: Flight, template_str: str | None = None) -> str: 268 + """Format flight information for a DM using Jinja2 template.""" 269 + template_str = template_str or DEFAULT_MESSAGE_TEMPLATE 270 + template = Template(template_str) 244 271 245 - parts.append(f"Flight: {flight.callsign or 'Unknown'}") 246 - parts.append(f"Distance: {flight.distance_miles} miles") 247 - 248 - if flight.altitude: 249 - parts.append(f"Altitude: {flight.altitude:,.0f} ft") 250 - if flight.ground_speed: 251 - parts.append(f"Speed: {flight.ground_speed:.0f} kts") 252 - if flight.heading: 253 - parts.append(f"Heading: {flight.heading:.0f}°") 254 - if flight.aircraft_type: 255 - parts.append(f"Aircraft: {flight.aircraft_type}") 256 - 257 - if flight.origin or flight.destination: 258 - route = f"{flight.origin or '???'} → {flight.destination or '???'}" 259 - parts.append(f"Route: {route}") 260 - 261 - parts.append(f"\nTime: {datetime.now().strftime('%H:%M:%S')}") 262 - 263 - return "\n".join(parts) 272 + return template.render( 273 + flight=flight, 274 + timestamp=datetime.now().strftime('%H:%M:%S') 275 + ) 264 276 265 277 266 278 def send_dm(client: Client, message: str, target_handle: str) -> bool: ··· 308 320 return False 309 321 310 322 323 + def flight_matches_filters(flight: Flight, filters: dict[str, list[str]]) -> bool: 324 + """Check if a flight matches the subscriber's filters.""" 325 + if not filters: 326 + return True 327 + 328 + for field, allowed_values in filters.items(): 329 + if not allowed_values: 330 + continue 331 + 332 + flight_value = getattr(flight, field, None) 333 + if flight_value is None: 334 + return False 335 + 336 + if field == "aircraft_type": 337 + # Case-insensitive partial matching for aircraft types 338 + flight_value_lower = str(flight_value).lower() 339 + if not any(allowed.lower() in flight_value_lower for allowed in allowed_values): 340 + return False 341 + else: 342 + # Exact matching for other fields 343 + if str(flight_value) not in [str(v) for v in allowed_values]: 344 + return False 345 + 346 + return True 347 + 348 + 311 349 def process_subscriber( 312 350 client: Client, 313 351 settings: Settings, ··· 324 362 notified_flights[subscriber.handle] = set() 325 363 326 364 subscriber_notified = notified_flights[subscriber.handle] 365 + filtered_count = 0 327 366 328 367 for flight in flights: 329 368 flight_id = flight.hex 369 + 370 + if not flight_matches_filters(flight, subscriber.filters): 371 + filtered_count += 1 372 + continue 330 373 331 374 if flight_id not in subscriber_notified: 332 - message = format_flight_info(flight) 375 + message = format_flight_info(flight, subscriber.message_template) 333 376 print(f"\n[{subscriber.handle}] {message}\n") 334 377 335 378 if send_dm(client, message, subscriber.handle): ··· 346 389 if not flights: 347 390 print( 348 391 f"[{subscriber.handle}] No flights in range at {datetime.now().strftime('%H:%M:%S')}" 392 + ) 393 + elif filtered_count > 0 and filtered_count == len(flights): 394 + print( 395 + f"[{subscriber.handle}] {filtered_count} flights filtered out at {datetime.now().strftime('%H:%M:%S')}" 349 396 ) 350 397 351 398 except Exception as e: ··· 391 438 help="BlueSky handle to DM (default: alternatebuild.dev)", 392 439 ) 393 440 parser.add_argument( 441 + "--filter-aircraft-type", 442 + type=str, 443 + nargs="+", 444 + help="Filter by aircraft types (e.g., B737 A320 C172)", 445 + ) 446 + parser.add_argument( 447 + "--filter-callsign", 448 + type=str, 449 + nargs="+", 450 + help="Filter by callsigns (e.g., UAL DL AAL)", 451 + ) 452 + parser.add_argument( 453 + "--filter-origin", 454 + type=str, 455 + nargs="+", 456 + help="Filter by origin airports (e.g., ORD LAX JFK)", 457 + ) 458 + parser.add_argument( 459 + "--filter-destination", 460 + type=str, 461 + nargs="+", 462 + help="Filter by destination airports (e.g., ORD LAX JFK)", 463 + ) 464 + parser.add_argument( 465 + "--message-template", 466 + type=str, 467 + help="Custom Jinja2 template for messages", 468 + ) 469 + parser.add_argument( 470 + "--message-template-file", 471 + type=str, 472 + help="Path to file containing custom Jinja2 template", 473 + ) 474 + parser.add_argument( 394 475 "--interval", 395 476 type=int, 396 477 default=60, ··· 437 518 print(f"Error loading subscribers: {e}") 438 519 return 439 520 else: 521 + # Build filters from CLI args 522 + filters = {} 523 + if args.filter_aircraft_type: 524 + filters["aircraft_type"] = args.filter_aircraft_type 525 + if args.filter_callsign: 526 + filters["callsign"] = args.filter_callsign 527 + if args.filter_origin: 528 + filters["origin"] = args.filter_origin 529 + if args.filter_destination: 530 + filters["destination"] = args.filter_destination 531 + 532 + # Load custom template if provided 533 + message_template = None 534 + if args.message_template_file: 535 + with open(args.message_template_file, "r") as f: 536 + message_template = f.read() 537 + elif args.message_template: 538 + message_template = args.message_template 539 + 440 540 subscribers = [ 441 541 Subscriber( 442 542 handle=args.handle, 443 543 latitude=args.latitude, 444 544 longitude=args.longitude, 445 545 radius_miles=args.radius, 546 + filters=filters, 547 + message_template=message_template, 446 548 ) 447 549 ] 448 550 print( 449 551 f"Monitoring flights within {args.radius} miles of ({args.latitude}, {args.longitude}) for {args.handle}" 450 552 ) 553 + if filters: 554 + print(f"Active filters: {filters}") 451 555 452 556 print(f"Checking every {args.interval} seconds...") 453 557
+13
sandbox/flight-notifier/.env
··· 1 + HUE_BRIDGE_IP=192.168.0.165 2 + HUE_BRIDGE_USERNAME=5KfCdRdTuTR0F1FgHTNL4E9rmHToRMNUSlfz1IaF 3 + 4 + # BSKY_HANDLE=alternatebuild.dev 5 + # BSKY_PASSWORD=MUSEUM3solarium6bower4sappy 6 + 7 + GITHUB_TOKEN=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 8 + 9 + FLIGHTRADAR_API_TOKEN=019872de-d599-7368-a704-d03532e9ad5f|WvTG09fMDNkfU7lRtXkh4hffoYU6dlRfY0QTdTX1dc12b55a 10 + 11 + # Bluesky credentials 12 + BSKY_HANDLE=phi.alternatebuild.dev 13 + BSKY_PASSWORD=cxha-k3ss-jhde-maj4
+253
sandbox/flight-notifier/DESIGN.md
··· 1 + # Flight Notifier Web App Design 2 + 3 + ## Overview 4 + 5 + A browser-based progressive web app that monitors flights overhead using device location and sends notifications via BlueSky DMs. Quick MVP for ngrok deployment with eventual path to production. 6 + 7 + ## Architecture 8 + 9 + ### MVP (ngrok deployment) 10 + ``` 11 + Browser (Geolocation) → FastAPI → FlightRadar24 API 12 + 13 + BlueSky DMs 14 + ``` 15 + 16 + ### Core Components 17 + 18 + 1. **Browser Frontend** 19 + - Single HTML page with vanilla JS (keep it simple) 20 + - Geolocation API for real-time position 21 + - WebSocket or polling for updates 22 + - Service Worker for background checks (future) 23 + - Local notifications + BlueSky DM option 24 + 25 + 2. **FastAPI Backend** 26 + - `/api/check-flights` - POST with lat/lon, returns flights 27 + - `/api/subscribe` - WebSocket endpoint for live updates 28 + - `/api/notify` - Send BlueSky DM for specific flight 29 + - Reuses existing flight monitoring script logic 30 + 31 + 3. **Deployment Strategy** 32 + - Phase 1: ngrok + local FastAPI (immediate) 33 + - Phase 2: Fly.io with proper auth (1-2 weeks) 34 + - Phase 3: PWA with service workers (1 month) 35 + 36 + ## User Experience 37 + 38 + ### MVP Flow 39 + 1. User visits ngrok URL 40 + 2. Browser asks for location permission 41 + 3. Big "Check Flights" button + auto-check toggle 42 + 4. Shows nearby flights with "Send DM" buttons 43 + 5. Enter BlueSky handle once, saved in localStorage 44 + 45 + ### Future Enhancements 46 + - Background notifications (requires HTTPS + service worker) 47 + - Flight filtering UI 48 + - Custom notification templates 49 + - Flight history/tracking 50 + - Multiple notification channels 51 + 52 + ## Technical Decisions 53 + 54 + ### Why Browser First? 55 + - Instant deployment, no app store 56 + - Geolocation API is mature 57 + - Works on all devices 58 + - Progressive enhancement path 59 + - Can add native later 60 + 61 + ### Why ngrok for MVP? 62 + - Zero deployment complexity 63 + - HTTPS for geolocation 64 + - Share with testers immediately 65 + - Iterate quickly 66 + 67 + ### Data Flow 68 + ```javascript 69 + // Browser 70 + navigator.geolocation.watchPosition(async (pos) => { 71 + const flights = await fetch('/api/check-flights', { 72 + method: 'POST', 73 + body: JSON.stringify({ 74 + latitude: pos.coords.latitude, 75 + longitude: pos.coords.longitude, 76 + radius_miles: 5 77 + }) 78 + }); 79 + // Update UI 80 + }); 81 + ``` 82 + 83 + ## Implementation Plan 84 + 85 + ### Today (MVP) 86 + 1. ✅ Create sandbox structure 87 + 2. Build minimal FastAPI server 88 + 3. HTML page with geolocation 89 + 4. Deploy with ngrok 90 + 5. Test with friends 91 + 92 + ### This Week 93 + 1. Add WebSocket for live updates 94 + 2. Better flight filtering UI 95 + 3. Notification preferences 96 + 4. Deploy to Fly.io 97 + 98 + ### Next Month 99 + 1. Service worker for background 100 + 2. Push notifications 101 + 3. Flight tracking/history 102 + 4. iOS/Android PWA polish 103 + 104 + ## Security Considerations (Post-MVP) 105 + 106 + ### Rate Limiting 107 + ```python 108 + # Per-user limits 109 + user_limits = { 110 + "checks_per_minute": 10, 111 + "dms_per_hour": 20, 112 + "websocket_connections": 1 113 + } 114 + ``` 115 + 116 + ### Authentication 117 + - BlueSky OAuth for production 118 + - Validate handle ownership 119 + - API keys for power users 120 + 121 + ### Privacy 122 + - Don't store location history 123 + - Clear position data on disconnect 124 + - GDPR-compliant data handling 125 + 126 + ## API Design 127 + 128 + ### Check Flights 129 + ``` 130 + POST /api/check-flights 131 + { 132 + "latitude": 41.8781, 133 + "longitude": -87.6298, 134 + "radius_miles": 5, 135 + "filters": { 136 + "aircraft_type": ["B737"] 137 + } 138 + } 139 + 140 + Response: 141 + { 142 + "flights": [ 143 + { 144 + "id": "abc123", 145 + "callsign": "UAL123", 146 + "aircraft_type": "B737", 147 + "distance_miles": 2.5, 148 + "altitude": 15000, 149 + "heading": 270 150 + } 151 + ] 152 + } 153 + ``` 154 + 155 + ### Send Notification 156 + ``` 157 + POST /api/notify 158 + { 159 + "flight_id": "abc123", 160 + "bsky_handle": "user.bsky.social", 161 + "template": "custom" 162 + } 163 + ``` 164 + 165 + ### WebSocket Subscribe 166 + ``` 167 + WS /api/subscribe 168 + → {"latitude": 41.8781, "longitude": -87.6298} 169 + ← {"type": "flight", "data": {...}} 170 + ← {"type": "flight_exit", "id": "abc123"} 171 + ``` 172 + 173 + ## Scaling Considerations 174 + 175 + ### Caching Strategy 176 + - Cache FlightRadar responses (15-30s) 177 + - Group nearby users for batch queries 178 + - Redis for shared state 179 + 180 + ### Database Schema (Future) 181 + ```sql 182 + CREATE TABLE users ( 183 + id UUID PRIMARY KEY, 184 + bsky_handle TEXT UNIQUE, 185 + created_at TIMESTAMP 186 + ); 187 + 188 + CREATE TABLE subscriptions ( 189 + user_id UUID REFERENCES users, 190 + latitude FLOAT, 191 + longitude FLOAT, 192 + radius_miles FLOAT, 193 + filters JSONB, 194 + active BOOLEAN 195 + ); 196 + 197 + CREATE TABLE notifications ( 198 + id UUID PRIMARY KEY, 199 + user_id UUID REFERENCES users, 200 + flight_id TEXT, 201 + sent_at TIMESTAMP 202 + ); 203 + ``` 204 + 205 + ## Development Notes 206 + 207 + ### Local Setup 208 + ```bash 209 + cd sandbox/flight-notifier 210 + uvicorn app:app --reload 211 + ngrok http 8000 212 + ``` 213 + 214 + ### Environment Variables 215 + ``` 216 + BSKY_HANDLE=bot.handle 217 + BSKY_PASSWORD=xxx 218 + FLIGHTRADAR_API_TOKEN=xxx 219 + ``` 220 + 221 + ### Testing 222 + - Use browser dev tools for geo spoofing 223 + - Test with multiple simultaneous users 224 + - Verify rate limits work 225 + - Check mobile experience 226 + 227 + ## Future Ideas 228 + 229 + ### iOS Shortcuts Integration 230 + ```javascript 231 + // Expose webhook for Shortcuts 232 + POST /api/shortcuts/check 233 + { 234 + "latitude": 41.8781, 235 + "longitude": -87.6298, 236 + "shortcut_callback": "shortcuts://callback" 237 + } 238 + ``` 239 + 240 + ### Flight Prediction 241 + - Learn user patterns 242 + - Notify before overhead 243 + - "Your usual 5pm flight approaching" 244 + 245 + ### Social Features 246 + - Share interesting flights 247 + - Local plane spotter groups 248 + - Flight photo integration 249 + 250 + ### Gamification 251 + - Spot rare aircraft 252 + - Track unique registrations 253 + - Monthly leaderboards
+69
sandbox/flight-notifier/README.md
··· 1 + # Flight Notifier Web App 2 + 3 + Browser-based flight monitoring with BlueSky DM notifications. 4 + 5 + ## Quick Start 6 + 7 + ```bash 8 + # Install dependencies 9 + just install 10 + 11 + # Run the app 12 + just dev 13 + 14 + # In another terminal, expose via ngrok 15 + just ngrok 16 + 17 + # Share the ngrok URL with testers! 18 + ``` 19 + 20 + ## How it Works 21 + 22 + 1. Browser asks for location permission 23 + 2. Click "Check Flights Now" or enable auto-check 24 + 3. Shows nearby flights with details 25 + 4. Click "Send BlueSky DM" to get notified 26 + 27 + ## Features 28 + 29 + - 🗺️ Real-time geolocation 30 + - ✈️ Live flight data from FlightRadar24 31 + - 📱 Mobile-friendly interface 32 + - 🔔 Browser notifications for new flights 33 + - 💬 BlueSky DM integration 34 + - 🔄 Auto-refresh every 30 seconds 35 + 36 + ## Development 37 + 38 + ```bash 39 + # Format code 40 + just fmt 41 + 42 + # Run linter 43 + just lint 44 + ``` 45 + 46 + ## Environment Variables 47 + 48 + Create a `.env` file: 49 + 50 + ``` 51 + BSKY_HANDLE=your-bot.bsky.social 52 + BSKY_PASSWORD=your-app-password 53 + FLIGHTRADAR_API_TOKEN=your-token 54 + ``` 55 + 56 + ## Architecture 57 + 58 + - FastAPI backend (`src/flight_notifier/main.py`) 59 + - Vanilla JS frontend (`static/index.html`) 60 + - Reuses flight monitoring logic from parent script 61 + - Ready for deployment to Fly.io or similar 62 + 63 + ## Future Enhancements 64 + 65 + - Service Workers for background monitoring 66 + - Push notifications 67 + - Flight filtering UI 68 + - User accounts & preferences 69 + - WebSocket for real-time updates
+17
sandbox/flight-notifier/justfile
··· 1 + # Core development commands 2 + dev: 3 + uv run uvicorn src.flight_notifier.main:app --reload 4 + 5 + ngrok: 6 + ngrok http 8000 7 + 8 + install: 9 + uv sync 10 + 11 + fmt: 12 + uv run ruff format src/ 13 + 14 + lint: 15 + uv run ruff check src/ 16 + 17 + check: lint
+25
sandbox/flight-notifier/pyproject.toml
··· 1 + [project] 2 + name = "flight-notifier" 3 + version = "0.1.0" 4 + description = "Browser-based flight monitoring with BlueSky notifications" 5 + readme = "README.md" 6 + authors = [{ name = "alternatebuild.dev" }] 7 + requires-python = ">=3.12" 8 + dependencies = [ 9 + "atproto", 10 + "fastapi", 11 + "geopy", 12 + "httpx", 13 + "jinja2", 14 + "pydantic-settings", 15 + "uvicorn", 16 + ] 17 + 18 + [tool.uv] 19 + dev-dependencies = [ 20 + "ruff", 21 + ] 22 + 23 + [build-system] 24 + requires = ["hatchling"] 25 + build-backend = "hatchling.build"
+3
sandbox/flight-notifier/src/flight_notifier/__init__.py
··· 1 + """Flight notifier web application.""" 2 + 3 + __version__ = "0.1.0"
sandbox/flight-notifier/src/flight_notifier/__pycache__/__init__.cpython-312.pyc

This is a binary file and will not be displayed.

sandbox/flight-notifier/src/flight_notifier/__pycache__/flight_monitor.cpython-312.pyc

This is a binary file and will not be displayed.

sandbox/flight-notifier/src/flight_notifier/__pycache__/main.cpython-312.pyc

This is a binary file and will not be displayed.

+472
sandbox/flight-notifier/src/flight_notifier/flight_monitor.py
··· 1 + """Flight monitoring utilities for BlueSky notifications.""" 2 + 3 + import argparse 4 + import time 5 + import math 6 + import json 7 + import sys 8 + from datetime import datetime 9 + from concurrent.futures import ThreadPoolExecutor, as_completed 10 + 11 + import httpx 12 + from atproto import Client 13 + from geopy import distance 14 + from jinja2 import Template 15 + from pydantic import BaseModel, Field 16 + from pydantic_settings import BaseSettings, SettingsConfigDict 17 + 18 + 19 + class Settings(BaseSettings): 20 + """App settings loaded from environment variables""" 21 + 22 + model_config = SettingsConfigDict(env_file=".env", extra="ignore") 23 + 24 + bsky_handle: str = Field(...) 25 + bsky_password: str = Field(...) 26 + flightradar_api_token: str = Field(...) 27 + 28 + 29 + class Subscriber(BaseModel): 30 + """Subscriber with location and notification preferences""" 31 + 32 + handle: str 33 + latitude: float 34 + longitude: float 35 + radius_miles: float = 5.0 36 + filters: dict[str, list[str]] = Field(default_factory=dict) 37 + message_template: str | None = None 38 + 39 + 40 + class Flight(BaseModel): 41 + """Flight data model""" 42 + 43 + hex: str 44 + latitude: float 45 + longitude: float 46 + altitude: float | None = None 47 + ground_speed: float | None = None 48 + heading: float | None = None 49 + aircraft_type: str | None = None 50 + registration: str | None = None 51 + origin: str | None = None 52 + destination: str | None = None 53 + callsign: str | None = None 54 + distance_miles: float 55 + 56 + 57 + def get_flights_in_area( 58 + settings: Settings, latitude: float, longitude: float, radius_miles: float 59 + ) -> list[Flight]: 60 + """Get flights within the specified radius using FlightRadar24 API.""" 61 + lat_offset = radius_miles / 69 # 1 degree latitude ≈ 69 miles 62 + lon_offset = radius_miles / (69 * abs(math.cos(math.radians(latitude)))) 63 + 64 + bounds = { 65 + "north": latitude + lat_offset, 66 + "south": latitude - lat_offset, 67 + "west": longitude - lon_offset, 68 + "east": longitude + lon_offset, 69 + } 70 + 71 + headers = { 72 + "Authorization": f"Bearer {settings.flightradar_api_token}", 73 + "Accept": "application/json", 74 + "Accept-Version": "v1", 75 + } 76 + 77 + url = "https://fr24api.flightradar24.com/api/live/flight-positions/full" 78 + params = { 79 + "bounds": f"{bounds['north']},{bounds['south']},{bounds['west']},{bounds['east']}" 80 + } 81 + 82 + try: 83 + with httpx.Client() as client: 84 + response = client.get(url, headers=headers, params=params, timeout=10) 85 + response.raise_for_status() 86 + data = response.json() 87 + 88 + flights_in_radius = [] 89 + center = (latitude, longitude) 90 + 91 + if isinstance(data, dict) and "data" in data: 92 + for flight_data in data["data"]: 93 + lat = flight_data.get("lat") 94 + lon = flight_data.get("lon") 95 + 96 + if lat and lon: 97 + flight_pos = (lat, lon) 98 + dist = distance.distance(center, flight_pos).miles 99 + if dist <= radius_miles: 100 + flight = Flight( 101 + hex=flight_data.get("fr24_id", ""), 102 + latitude=lat, 103 + longitude=lon, 104 + altitude=flight_data.get("alt"), 105 + ground_speed=flight_data.get("gspeed"), 106 + heading=flight_data.get("track"), 107 + aircraft_type=flight_data.get("type"), 108 + registration=flight_data.get("reg"), 109 + origin=flight_data.get("orig_iata"), 110 + destination=flight_data.get("dest_iata"), 111 + callsign=flight_data.get("flight"), 112 + distance_miles=round(dist, 2), 113 + ) 114 + flights_in_radius.append(flight) 115 + 116 + return flights_in_radius 117 + except httpx.HTTPStatusError as e: 118 + print(f"HTTP error fetching flights: {e}") 119 + print(f"Response status: {e.response.status_code}") 120 + print(f"Response content: {e.response.text[:500]}") 121 + return [] 122 + except Exception as e: 123 + print(f"Error fetching flights: {e}") 124 + return [] 125 + 126 + 127 + DEFAULT_MESSAGE_TEMPLATE = """✈️ Flight passing overhead! 128 + 129 + Flight: {{ flight.callsign or 'Unknown' }} 130 + Distance: {{ flight.distance_miles }} miles 131 + {%- if flight.altitude %} 132 + Altitude: {{ "{:,.0f}".format(flight.altitude) }} ft 133 + {%- endif %} 134 + {%- if flight.ground_speed %} 135 + Speed: {{ "{:.0f}".format(flight.ground_speed) }} kts 136 + {%- endif %} 137 + {%- if flight.heading %} 138 + Heading: {{ "{:.0f}".format(flight.heading) }}° 139 + {%- endif %} 140 + {%- if flight.aircraft_type %} 141 + Aircraft: {{ flight.aircraft_type }} 142 + {%- endif %} 143 + {%- if flight.origin or flight.destination %} 144 + Route: {{ flight.origin or '???' }} → {{ flight.destination or '???' }} 145 + {%- endif %} 146 + 147 + Time: {{ timestamp }}""" 148 + 149 + 150 + def format_flight_info(flight: Flight, template_str: str | None = None) -> str: 151 + """Format flight information for a DM using Jinja2 template.""" 152 + template_str = template_str or DEFAULT_MESSAGE_TEMPLATE 153 + template = Template(template_str) 154 + 155 + return template.render( 156 + flight=flight, 157 + timestamp=datetime.now().strftime('%H:%M:%S') 158 + ) 159 + 160 + 161 + def send_dm(client: Client, message: str, target_handle: str) -> bool: 162 + """Send a direct message to the specified handle on BlueSky.""" 163 + try: 164 + resolved = client.com.atproto.identity.resolve_handle( 165 + params={"handle": target_handle} 166 + ) 167 + target_did = resolved.did 168 + 169 + chat_client = client.with_bsky_chat_proxy() 170 + 171 + convo_response = chat_client.chat.bsky.convo.get_convo_for_members( 172 + {"members": [target_did]} 173 + ) 174 + 175 + if not convo_response or not convo_response.convo: 176 + print(f"Could not create/get conversation with {target_handle}") 177 + return False 178 + 179 + recipient = None 180 + for member in convo_response.convo.members: 181 + if member.did != client.me.did: 182 + recipient = member 183 + break 184 + 185 + if not recipient or recipient.handle != target_handle: 186 + print( 187 + f"ERROR: About to message wrong person! Expected {target_handle}, but found {recipient.handle if recipient else 'no recipient'}" 188 + ) 189 + return False 190 + 191 + chat_client.chat.bsky.convo.send_message( 192 + data={ 193 + "convoId": convo_response.convo.id, 194 + "message": {"text": message, "facets": []}, 195 + } 196 + ) 197 + 198 + print(f"DM sent to {target_handle}") 199 + return True 200 + 201 + except Exception as e: 202 + print(f"Error sending DM to {target_handle}: {e}") 203 + return False 204 + 205 + 206 + def flight_matches_filters(flight: Flight, filters: dict[str, list[str]]) -> bool: 207 + """Check if a flight matches the subscriber's filters.""" 208 + if not filters: 209 + return True 210 + 211 + for field, allowed_values in filters.items(): 212 + if not allowed_values: 213 + continue 214 + 215 + flight_value = getattr(flight, field, None) 216 + if flight_value is None: 217 + return False 218 + 219 + if field == "aircraft_type": 220 + # Case-insensitive partial matching for aircraft types 221 + flight_value_lower = str(flight_value).lower() 222 + if not any(allowed.lower() in flight_value_lower for allowed in allowed_values): 223 + return False 224 + else: 225 + # Exact matching for other fields 226 + if str(flight_value) not in [str(v) for v in allowed_values]: 227 + return False 228 + 229 + return True 230 + 231 + 232 + def process_subscriber( 233 + client: Client, 234 + settings: Settings, 235 + subscriber: Subscriber, 236 + notified_flights: dict[str, set[str]], 237 + ) -> None: 238 + """Process flights for a single subscriber.""" 239 + try: 240 + flights = get_flights_in_area( 241 + settings, subscriber.latitude, subscriber.longitude, subscriber.radius_miles 242 + ) 243 + 244 + if subscriber.handle not in notified_flights: 245 + notified_flights[subscriber.handle] = set() 246 + 247 + subscriber_notified = notified_flights[subscriber.handle] 248 + filtered_count = 0 249 + 250 + for flight in flights: 251 + flight_id = flight.hex 252 + 253 + if not flight_matches_filters(flight, subscriber.filters): 254 + filtered_count += 1 255 + continue 256 + 257 + if flight_id not in subscriber_notified: 258 + message = format_flight_info(flight, subscriber.message_template) 259 + print(f"\n[{subscriber.handle}] {message}\n") 260 + 261 + if send_dm(client, message, subscriber.handle): 262 + print(f"DM sent to {subscriber.handle} for flight {flight_id}") 263 + subscriber_notified.add(flight_id) 264 + else: 265 + print( 266 + f"Failed to send DM to {subscriber.handle} for flight {flight_id}" 267 + ) 268 + 269 + current_flight_ids = {f.hex for f in flights} 270 + notified_flights[subscriber.handle] &= current_flight_ids 271 + 272 + if not flights: 273 + print( 274 + f"[{subscriber.handle}] No flights in range at {datetime.now().strftime('%H:%M:%S')}" 275 + ) 276 + elif filtered_count > 0 and filtered_count == len(flights): 277 + print( 278 + f"[{subscriber.handle}] {filtered_count} flights filtered out at {datetime.now().strftime('%H:%M:%S')}" 279 + ) 280 + 281 + except Exception as e: 282 + print(f"Error processing subscriber {subscriber.handle}: {e}") 283 + 284 + 285 + def load_subscribers(subscribers_input: str | None) -> list[Subscriber]: 286 + """Load subscribers from JSON file or stdin.""" 287 + if subscribers_input: 288 + with open(subscribers_input, "r") as f: 289 + data = json.load(f) 290 + else: 291 + print("Reading subscriber data from stdin (provide JSON array)...") 292 + data = json.load(sys.stdin) 293 + 294 + return [Subscriber(**item) for item in data] 295 + 296 + 297 + def main(): 298 + """Main monitoring loop.""" 299 + parser = argparse.ArgumentParser( 300 + description="Monitor flights overhead and send BlueSky DMs" 301 + ) 302 + 303 + parser.add_argument( 304 + "--subscribers", 305 + type=str, 306 + help="JSON file with subscriber list, or '-' for stdin", 307 + ) 308 + parser.add_argument( 309 + "--latitude", type=float, default=41.8781, help="Latitude (default: Chicago)" 310 + ) 311 + parser.add_argument( 312 + "--longitude", type=float, default=-87.6298, help="Longitude (default: Chicago)" 313 + ) 314 + parser.add_argument( 315 + "--radius", type=float, default=5.0, help="Radius in miles (default: 5)" 316 + ) 317 + parser.add_argument( 318 + "--handle", 319 + type=str, 320 + default="alternatebuild.dev", 321 + help="BlueSky handle to DM (default: alternatebuild.dev)", 322 + ) 323 + parser.add_argument( 324 + "--filter-aircraft-type", 325 + type=str, 326 + nargs="+", 327 + help="Filter by aircraft types (e.g., B737 A320 C172)", 328 + ) 329 + parser.add_argument( 330 + "--filter-callsign", 331 + type=str, 332 + nargs="+", 333 + help="Filter by callsigns (e.g., UAL DL AAL)", 334 + ) 335 + parser.add_argument( 336 + "--filter-origin", 337 + type=str, 338 + nargs="+", 339 + help="Filter by origin airports (e.g., ORD LAX JFK)", 340 + ) 341 + parser.add_argument( 342 + "--filter-destination", 343 + type=str, 344 + nargs="+", 345 + help="Filter by destination airports (e.g., ORD LAX JFK)", 346 + ) 347 + parser.add_argument( 348 + "--message-template", 349 + type=str, 350 + help="Custom Jinja2 template for messages", 351 + ) 352 + parser.add_argument( 353 + "--message-template-file", 354 + type=str, 355 + help="Path to file containing custom Jinja2 template", 356 + ) 357 + parser.add_argument( 358 + "--interval", 359 + type=int, 360 + default=60, 361 + help="Check interval in seconds (default: 60)", 362 + ) 363 + parser.add_argument( 364 + "--once", action="store_true", help="Run once and exit (for testing)" 365 + ) 366 + parser.add_argument( 367 + "--max-workers", 368 + type=int, 369 + default=5, 370 + help="Max concurrent workers for processing subscribers (default: 5)", 371 + ) 372 + args = parser.parse_args() 373 + 374 + try: 375 + settings = Settings() 376 + except Exception as e: 377 + print(f"Error loading settings: {e}") 378 + print( 379 + "Ensure .env file exists with BSKY_HANDLE, BSKY_PASSWORD, and FLIGHTRADAR_API_TOKEN" 380 + ) 381 + return 382 + 383 + client = Client() 384 + try: 385 + client.login(settings.bsky_handle, settings.bsky_password) 386 + print(f"Logged in to BlueSky as {settings.bsky_handle}") 387 + except Exception as e: 388 + print(f"Error logging into BlueSky: {e}") 389 + return 390 + 391 + if args.subscribers: 392 + if args.subscribers == "-": 393 + subscribers_input = None 394 + else: 395 + subscribers_input = args.subscribers 396 + 397 + try: 398 + subscribers = load_subscribers(subscribers_input) 399 + print(f"Loaded {len(subscribers)} subscriber(s)") 400 + except Exception as e: 401 + print(f"Error loading subscribers: {e}") 402 + return 403 + else: 404 + # Build filters from CLI args 405 + filters = {} 406 + if args.filter_aircraft_type: 407 + filters["aircraft_type"] = args.filter_aircraft_type 408 + if args.filter_callsign: 409 + filters["callsign"] = args.filter_callsign 410 + if args.filter_origin: 411 + filters["origin"] = args.filter_origin 412 + if args.filter_destination: 413 + filters["destination"] = args.filter_destination 414 + 415 + # Load custom template if provided 416 + message_template = None 417 + if args.message_template_file: 418 + with open(args.message_template_file, "r") as f: 419 + message_template = f.read() 420 + elif args.message_template: 421 + message_template = args.message_template 422 + 423 + subscribers = [ 424 + Subscriber( 425 + handle=args.handle, 426 + latitude=args.latitude, 427 + longitude=args.longitude, 428 + radius_miles=args.radius, 429 + filters=filters, 430 + message_template=message_template, 431 + ) 432 + ] 433 + print( 434 + f"Monitoring flights within {args.radius} miles of ({args.latitude}, {args.longitude}) for {args.handle}" 435 + ) 436 + if filters: 437 + print(f"Active filters: {filters}") 438 + 439 + print(f"Checking every {args.interval} seconds...") 440 + 441 + notified_flights: dict[str, set[str]] = {} 442 + 443 + while True: 444 + try: 445 + with ThreadPoolExecutor(max_workers=args.max_workers) as executor: 446 + futures = [] 447 + for subscriber in subscribers: 448 + future = executor.submit( 449 + process_subscriber, 450 + client, 451 + settings, 452 + subscriber, 453 + notified_flights, 454 + ) 455 + futures.append(future) 456 + 457 + for future in as_completed(futures): 458 + future.result() 459 + 460 + if args.once: 461 + break 462 + 463 + time.sleep(args.interval) 464 + 465 + except KeyboardInterrupt: 466 + print("\nStopping flight monitor...") 467 + break 468 + except Exception as e: 469 + print(f"Error in monitoring loop: {e}") 470 + time.sleep(args.interval) 471 + 472 +
+111
sandbox/flight-notifier/src/flight_notifier/main.py
··· 1 + """FastAPI backend for flight notifier web app.""" 2 + 3 + from datetime import datetime 4 + 5 + from atproto import Client 6 + from fastapi import FastAPI, HTTPException 7 + from fastapi.middleware.cors import CORSMiddleware 8 + from fastapi.responses import HTMLResponse 9 + from pydantic import BaseModel, Field 10 + 11 + from flight_notifier.flight_monitor import ( 12 + Flight, 13 + Settings, 14 + flight_matches_filters, 15 + format_flight_info, 16 + get_flights_in_area, 17 + send_dm, 18 + ) 19 + 20 + app = FastAPI(title="Flight Notifier") 21 + 22 + # Enable CORS for development 23 + app.add_middleware( 24 + CORSMiddleware, 25 + allow_origins=["*"], 26 + allow_credentials=True, 27 + allow_methods=["*"], 28 + allow_headers=["*"], 29 + ) 30 + 31 + # Global state (TODO: use Redis) 32 + settings = Settings() 33 + bsky_client = Client() 34 + try: 35 + bsky_client.login(settings.bsky_handle, settings.bsky_password) 36 + print(f"Logged in to BlueSky as {settings.bsky_handle}") 37 + except Exception as e: 38 + print(f"Warning: Could not login to BlueSky: {e}") 39 + bsky_client = None 40 + 41 + 42 + class CheckFlightsRequest(BaseModel): 43 + latitude: float 44 + longitude: float 45 + radius_miles: float = 5.0 46 + filters: dict[str, list[str]] = Field(default_factory=dict) 47 + 48 + 49 + class NotifyRequest(BaseModel): 50 + flight: dict 51 + bsky_handle: str 52 + template: str | None = None 53 + 54 + 55 + class FlightResponse(BaseModel): 56 + flights: list[Flight] 57 + timestamp: str 58 + 59 + 60 + @app.get("/") 61 + async def root() -> HTMLResponse: 62 + """Serve the main web interface.""" 63 + with open("static/index.html", "r") as f: 64 + return HTMLResponse(content=f.read()) 65 + 66 + 67 + @app.post("/api/check-flights") 68 + async def check_flights(request: CheckFlightsRequest) -> FlightResponse: 69 + """Check for flights in the specified area.""" 70 + try: 71 + flights = get_flights_in_area( 72 + settings, request.latitude, request.longitude, request.radius_miles 73 + ) 74 + 75 + # Apply filters if provided 76 + if request.filters: 77 + flights = [f for f in flights if flight_matches_filters(f, request.filters)] 78 + 79 + return FlightResponse(flights=flights, timestamp=datetime.now().isoformat()) 80 + except Exception as e: 81 + raise HTTPException(status_code=500, detail=str(e)) 82 + 83 + 84 + @app.post("/api/notify") 85 + async def notify(request: NotifyRequest) -> dict[str, str]: 86 + """Send a BlueSky DM notification for a flight.""" 87 + if not bsky_client: 88 + raise HTTPException(status_code=503, detail="BlueSky client not available") 89 + 90 + try: 91 + # Reconstruct Flight object 92 + flight = Flight(**request.flight) 93 + message = format_flight_info(flight, request.template) 94 + 95 + success = send_dm(bsky_client, message, request.bsky_handle) 96 + if not success: 97 + raise HTTPException(status_code=500, detail="Failed to send DM") 98 + 99 + return {"status": "sent", "message": message} 100 + except Exception as e: 101 + raise HTTPException(status_code=500, detail=str(e)) 102 + 103 + 104 + @app.get("/api/health") 105 + async def health() -> dict[str, str | bool]: 106 + """Health check endpoint.""" 107 + return { 108 + "status": "ok", 109 + "bsky_connected": bsky_client is not None, 110 + "timestamp": datetime.now().isoformat(), 111 + }
+643
sandbox/flight-notifier/static/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="UTF-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1.0"> 6 + <title>Flight Notifier</title> 7 + <style> 8 + * { 9 + margin: 0; 10 + padding: 0; 11 + box-sizing: border-box; 12 + } 13 + 14 + body { 15 + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; 16 + background: #0a0e27; 17 + color: #e4e6eb; 18 + min-height: 100vh; 19 + padding: 20px; 20 + line-height: 1.6; 21 + } 22 + 23 + .container { 24 + max-width: 600px; 25 + margin: 0 auto; 26 + } 27 + 28 + h1 { 29 + text-align: center; 30 + margin-bottom: 30px; 31 + font-size: 2rem; 32 + } 33 + 34 + .controls { 35 + background: #1c1e21; 36 + padding: 20px; 37 + border-radius: 12px; 38 + margin-bottom: 20px; 39 + } 40 + 41 + .location-status { 42 + padding: 10px; 43 + background: #2d2f33; 44 + border-radius: 8px; 45 + margin-bottom: 15px; 46 + font-size: 0.9rem; 47 + } 48 + 49 + .location-status.active { 50 + background: #0f4c5c; 51 + } 52 + 53 + .button-group { 54 + display: flex; 55 + gap: 10px; 56 + margin-bottom: 15px; 57 + } 58 + 59 + button { 60 + flex: 1; 61 + padding: 12px 20px; 62 + border: none; 63 + border-radius: 8px; 64 + font-size: 1rem; 65 + cursor: pointer; 66 + transition: all 0.2s; 67 + } 68 + 69 + .btn-primary { 70 + background: #00a8cc; 71 + color: white; 72 + } 73 + 74 + .btn-primary:hover { 75 + background: #0090ad; 76 + } 77 + 78 + .btn-secondary { 79 + background: #3a3b3c; 80 + color: #e4e6eb; 81 + } 82 + 83 + .btn-secondary:hover { 84 + background: #4a4b4c; 85 + } 86 + 87 + .input-group { 88 + margin-bottom: 15px; 89 + } 90 + 91 + label { 92 + display: block; 93 + margin-bottom: 5px; 94 + font-size: 0.9rem; 95 + color: #b0b3b8; 96 + } 97 + 98 + input { 99 + width: 100%; 100 + padding: 10px; 101 + background: #2d2f33; 102 + border: 1px solid #3a3b3c; 103 + border-radius: 8px; 104 + color: #e4e6eb; 105 + font-size: 1rem; 106 + } 107 + 108 + .toggle { 109 + display: flex; 110 + align-items: center; 111 + gap: 10px; 112 + margin-bottom: 15px; 113 + } 114 + 115 + .toggle input { 116 + width: auto; 117 + } 118 + 119 + .flights { 120 + background: #1c1e21; 121 + padding: 20px; 122 + border-radius: 12px; 123 + min-height: 200px; 124 + } 125 + 126 + .flight-card { 127 + background: #2d2f33; 128 + padding: 15px; 129 + border-radius: 8px; 130 + margin-bottom: 15px; 131 + position: relative; 132 + } 133 + 134 + .flight-header { 135 + display: flex; 136 + justify-content: space-between; 137 + align-items: center; 138 + margin-bottom: 10px; 139 + } 140 + 141 + .flight-callsign { 142 + font-size: 1.2rem; 143 + font-weight: bold; 144 + } 145 + 146 + .flight-distance { 147 + background: #00a8cc; 148 + padding: 4px 12px; 149 + border-radius: 20px; 150 + font-size: 0.85rem; 151 + } 152 + 153 + .flight-details { 154 + display: grid; 155 + grid-template-columns: repeat(2, 1fr); 156 + gap: 8px; 157 + margin-bottom: 10px; 158 + font-size: 0.9rem; 159 + color: #b0b3b8; 160 + } 161 + 162 + .dm-button { 163 + width: 100%; 164 + padding: 10px; 165 + background: #5865f2; 166 + color: white; 167 + border: none; 168 + border-radius: 6px; 169 + cursor: pointer; 170 + font-size: 0.9rem; 171 + } 172 + 173 + .dm-button:hover { 174 + background: #4752c4; 175 + } 176 + 177 + .no-flights { 178 + text-align: center; 179 + color: #b0b3b8; 180 + padding: 40px; 181 + } 182 + 183 + .loading { 184 + text-align: center; 185 + padding: 40px; 186 + } 187 + 188 + .error { 189 + background: #3a2328; 190 + color: #f8a5a5; 191 + padding: 15px; 192 + border-radius: 8px; 193 + margin-bottom: 15px; 194 + } 195 + 196 + .filters-section { 197 + margin-top: 20px; 198 + background: #2d2f33; 199 + padding: 15px; 200 + border-radius: 8px; 201 + cursor: pointer; 202 + } 203 + 204 + .filters-section summary { 205 + font-weight: 600; 206 + padding: 5px; 207 + user-select: none; 208 + } 209 + 210 + .filters-section summary:hover { 211 + color: #00a8cc; 212 + } 213 + 214 + .filters-content { 215 + margin-top: 15px; 216 + padding-top: 15px; 217 + border-top: 1px solid #3a3b3c; 218 + } 219 + 220 + .filters-content small { 221 + color: #8b8d91; 222 + font-size: 0.85rem; 223 + display: block; 224 + margin-top: 2px; 225 + } 226 + 227 + .filter-presets { 228 + display: flex; 229 + gap: 8px; 230 + margin-top: 15px; 231 + flex-wrap: wrap; 232 + } 233 + 234 + .preset-btn { 235 + padding: 8px 16px; 236 + font-size: 0.9rem; 237 + background: #3a3b3c; 238 + border: 1px solid #4a4b4c; 239 + flex: 1; 240 + min-width: 120px; 241 + } 242 + 243 + .preset-btn:hover { 244 + background: #4a4b4c; 245 + border-color: #00a8cc; 246 + } 247 + 248 + .flight-type { 249 + display: inline-block; 250 + padding: 2px 8px; 251 + border-radius: 4px; 252 + font-size: 0.8rem; 253 + margin-left: 8px; 254 + background: #4a4b4c; 255 + } 256 + 257 + .flight-type.military { 258 + background: #5c4c8c; 259 + } 260 + 261 + .flight-type.commercial { 262 + background: #2c5c8c; 263 + } 264 + 265 + .flight-type.ga { 266 + background: #5c8c4c; 267 + } 268 + 269 + @keyframes pulse { 270 + 0% { opacity: 0.6; } 271 + 50% { opacity: 1; } 272 + 100% { opacity: 0.6; } 273 + } 274 + 275 + .flight-card.new { 276 + animation: pulse 2s ease-in-out; 277 + border: 1px solid #00a8cc; 278 + } 279 + </style> 280 + </head> 281 + <body> 282 + <div class="container"> 283 + <h1>✈️ Flight Notifier</h1> 284 + 285 + <div class="controls"> 286 + <div class="location-status" id="locationStatus"> 287 + 📍 Location: Not available 288 + </div> 289 + 290 + <div class="button-group"> 291 + <button class="btn-primary" onclick="checkFlights()"> 292 + Check Flights Now 293 + </button> 294 + <button class="btn-secondary" onclick="requestLocation()"> 295 + Update Location 296 + </button> 297 + </div> 298 + 299 + <div class="toggle"> 300 + <input type="checkbox" id="autoCheck" onchange="toggleAutoCheck()"> 301 + <label for="autoCheck">Auto-check every 30 seconds</label> 302 + </div> 303 + 304 + <div class="input-group"> 305 + <label for="bskyHandle">BlueSky Handle (for DMs)</label> 306 + <input type="text" id="bskyHandle" placeholder="yourhandle.bsky.social" 307 + value="alternatebuild.dev"> 308 + </div> 309 + 310 + <div class="input-group"> 311 + <label for="radiusMiles">Search Radius (miles)</label> 312 + <input type="number" id="radiusMiles" value="5" min="1" max="50"> 313 + </div> 314 + 315 + <details class="filters-section"> 316 + <summary>✈️ Advanced Filters</summary> 317 + <div class="filters-content"> 318 + <div class="input-group"> 319 + <label for="aircraftTypes">Aircraft Types</label> 320 + <input type="text" id="aircraftTypes" placeholder="e.g., B737, A320, C172 (comma separated)"> 321 + <small>Filter by aircraft model (partial match)</small> 322 + </div> 323 + 324 + <div class="input-group"> 325 + <label for="airlines">Airlines</label> 326 + <input type="text" id="airlines" placeholder="e.g., UAL, AAL, DAL (comma separated)"> 327 + <small>Filter by airline callsign prefix</small> 328 + </div> 329 + 330 + <div class="input-group"> 331 + <label for="origins">Origin Airports</label> 332 + <input type="text" id="origins" placeholder="e.g., ORD, LAX, JFK (comma separated)"> 333 + <small>Filter by departure airport</small> 334 + </div> 335 + 336 + <div class="input-group"> 337 + <label for="destinations">Destination Airports</label> 338 + <input type="text" id="destinations" placeholder="e.g., ORD, LAX, JFK (comma separated)"> 339 + <small>Filter by arrival airport</small> 340 + </div> 341 + 342 + <div class="filter-presets"> 343 + <button class="preset-btn" onclick="setPreset('military')">🚁 Military</button> 344 + <button class="preset-btn" onclick="setPreset('commercial')">🛫 Commercial</button> 345 + <button class="preset-btn" onclick="setPreset('ga')">🛩️ General Aviation</button> 346 + <button class="preset-btn" onclick="clearFilters()">❌ Clear</button> 347 + </div> 348 + </div> 349 + </details> 350 + </div> 351 + 352 + <div class="flights" id="flightsContainer"> 353 + <div class="no-flights"> 354 + Press "Check Flights Now" to search for aircraft overhead 355 + </div> 356 + </div> 357 + </div> 358 + 359 + <script> 360 + let currentPosition = null; 361 + let autoCheckInterval = null; 362 + let lastFlightIds = new Set(); 363 + 364 + // Load saved preferences 365 + const savedHandle = localStorage.getItem('bskyHandle'); 366 + if (savedHandle) { 367 + document.getElementById('bskyHandle').value = savedHandle; 368 + } 369 + 370 + // Request location on load 371 + window.addEventListener('load', () => { 372 + requestLocation(); 373 + }); 374 + 375 + function requestLocation() { 376 + if (!navigator.geolocation) { 377 + showError('Geolocation is not supported by your browser'); 378 + return; 379 + } 380 + 381 + const status = document.getElementById('locationStatus'); 382 + status.textContent = '📍 Getting location...'; 383 + 384 + navigator.geolocation.getCurrentPosition( 385 + (position) => { 386 + currentPosition = position.coords; 387 + status.textContent = `📍 Location: ${position.coords.latitude.toFixed(4)}, ${position.coords.longitude.toFixed(4)}`; 388 + status.classList.add('active'); 389 + }, 390 + (error) => { 391 + status.textContent = '📍 Location access denied'; 392 + status.classList.remove('active'); 393 + showError('Unable to get location: ' + error.message); 394 + } 395 + ); 396 + } 397 + 398 + function getActiveFilters() { 399 + const filters = {}; 400 + 401 + const aircraftTypes = document.getElementById('aircraftTypes').value; 402 + if (aircraftTypes) { 403 + filters.aircraft_type = aircraftTypes.split(',').map(t => t.trim()).filter(t => t); 404 + } 405 + 406 + const airlines = document.getElementById('airlines').value; 407 + if (airlines) { 408 + filters.callsign = airlines.split(',').map(a => a.trim()).filter(a => a); 409 + } 410 + 411 + const origins = document.getElementById('origins').value; 412 + if (origins) { 413 + filters.origin = origins.split(',').map(o => o.trim()).filter(o => o); 414 + } 415 + 416 + const destinations = document.getElementById('destinations').value; 417 + if (destinations) { 418 + filters.destination = destinations.split(',').map(d => d.trim()).filter(d => d); 419 + } 420 + 421 + return filters; 422 + } 423 + 424 + async function checkFlights() { 425 + if (!currentPosition) { 426 + showError('Please allow location access first'); 427 + requestLocation(); 428 + return; 429 + } 430 + 431 + const container = document.getElementById('flightsContainer'); 432 + container.innerHTML = '<div class="loading">🔍 Searching for flights...</div>'; 433 + 434 + try { 435 + const radius = document.getElementById('radiusMiles').value; 436 + const filters = getActiveFilters(); 437 + 438 + const response = await fetch('/api/check-flights', { 439 + method: 'POST', 440 + headers: { 441 + 'Content-Type': 'application/json', 442 + }, 443 + body: JSON.stringify({ 444 + latitude: currentPosition.latitude, 445 + longitude: currentPosition.longitude, 446 + radius_miles: parseFloat(radius), 447 + filters: filters 448 + }) 449 + }); 450 + 451 + if (!response.ok) { 452 + throw new Error('Failed to fetch flights'); 453 + } 454 + 455 + const data = await response.json(); 456 + displayFlights(data.flights); 457 + 458 + // Check for new flights 459 + const currentFlightIds = new Set(data.flights.map(f => f.hex)); 460 + for (const flight of data.flights) { 461 + if (!lastFlightIds.has(flight.hex)) { 462 + // New flight detected! 463 + if (lastFlightIds.size > 0) { // Don't notify on first check 464 + notifyNewFlight(flight); 465 + } 466 + } 467 + } 468 + lastFlightIds = currentFlightIds; 469 + 470 + } catch (error) { 471 + showError('Error checking flights: ' + error.message); 472 + } 473 + } 474 + 475 + function getFlightType(flight) { 476 + const type = flight.aircraft_type?.toUpperCase() || ''; 477 + const callsign = flight.callsign?.toUpperCase() || ''; 478 + 479 + // Military aircraft 480 + if (type.match(/UH|AH|CH|MH|HH|F\d|B52|C130|C17|KC|E\d|P\d|T38/) || 481 + callsign.match(/^(RCH|REACH|VIPER|EAGLE|HAWK)/)) { 482 + return 'military'; 483 + } 484 + 485 + // General aviation 486 + if (type.match(/C172|C182|PA|SR2|DA4|PC12|TBM|M20/) || 487 + flight.altitude && flight.altitude < 10000) { 488 + return 'ga'; 489 + } 490 + 491 + // Commercial 492 + if (type.match(/B7|A3|A2|CRJ|E\d{3}|MD/) || 493 + callsign.match(/^(UAL|AAL|DAL|SWA|JBU|NKS|FFT)/)) { 494 + return 'commercial'; 495 + } 496 + 497 + return ''; 498 + } 499 + 500 + function displayFlights(flights) { 501 + const container = document.getElementById('flightsContainer'); 502 + 503 + if (flights.length === 0) { 504 + container.innerHTML = '<div class="no-flights">No flights in range</div>'; 505 + return; 506 + } 507 + 508 + container.innerHTML = flights.map(flight => { 509 + const flightType = getFlightType(flight); 510 + const isNew = !lastFlightIds.has(flight.hex); 511 + const typeLabel = flightType ? `<span class="flight-type ${flightType}">${flightType}</span>` : ''; 512 + 513 + return ` 514 + <div class="flight-card ${isNew ? 'new' : ''}"> 515 + <div class="flight-header"> 516 + <div class="flight-callsign"> 517 + ${flight.callsign || 'Unknown'} 518 + ${typeLabel} 519 + </div> 520 + <div class="flight-distance">${flight.distance_miles} mi</div> 521 + </div> 522 + <div class="flight-details"> 523 + ${flight.altitude ? `<div>Altitude: ${flight.altitude.toLocaleString()} ft</div>` : ''} 524 + ${flight.ground_speed ? `<div>Speed: ${Math.round(flight.ground_speed)} kts</div>` : ''} 525 + ${flight.heading ? `<div>Heading: ${Math.round(flight.heading)}°</div>` : ''} 526 + ${flight.aircraft_type ? `<div>Aircraft: ${flight.aircraft_type}</div>` : ''} 527 + ${flight.registration ? `<div>Registration: ${flight.registration}</div>` : ''} 528 + ${flight.origin || flight.destination ? 529 + `<div>Route: ${flight.origin || '???'} → ${flight.destination || '???'}</div>` : ''} 530 + </div> 531 + <button class="dm-button" onclick='sendDM(${JSON.stringify(flight)})'> 532 + Send BlueSky DM 533 + </button> 534 + </div> 535 + `; 536 + }).join(''); 537 + } 538 + 539 + async function sendDM(flight) { 540 + const handle = document.getElementById('bskyHandle').value; 541 + if (!handle) { 542 + showError('Please enter your BlueSky handle'); 543 + return; 544 + } 545 + 546 + // Save handle for next time 547 + localStorage.setItem('bskyHandle', handle); 548 + 549 + try { 550 + const response = await fetch('/api/notify', { 551 + method: 'POST', 552 + headers: { 553 + 'Content-Type': 'application/json', 554 + }, 555 + body: JSON.stringify({ 556 + flight: flight, 557 + bsky_handle: handle 558 + }) 559 + }); 560 + 561 + if (!response.ok) { 562 + throw new Error('Failed to send DM'); 563 + } 564 + 565 + // Visual feedback 566 + alert('DM sent successfully!'); 567 + } catch (error) { 568 + showError('Error sending DM: ' + error.message); 569 + } 570 + } 571 + 572 + function toggleAutoCheck() { 573 + const checkbox = document.getElementById('autoCheck'); 574 + 575 + if (checkbox.checked) { 576 + // Start auto-checking 577 + checkFlights(); // Check immediately 578 + autoCheckInterval = setInterval(checkFlights, 30000); // Then every 30s 579 + } else { 580 + // Stop auto-checking 581 + if (autoCheckInterval) { 582 + clearInterval(autoCheckInterval); 583 + autoCheckInterval = null; 584 + } 585 + } 586 + } 587 + 588 + function showError(message) { 589 + const container = document.getElementById('flightsContainer'); 590 + container.innerHTML = `<div class="error">❌ ${message}</div>` + container.innerHTML; 591 + } 592 + 593 + function notifyNewFlight(flight) { 594 + // Browser notification if permitted 595 + if ("Notification" in window && Notification.permission === "granted") { 596 + new Notification(`✈️ New flight overhead!`, { 597 + body: `${flight.callsign || 'Unknown'} - ${flight.distance_miles} miles away`, 598 + icon: '/favicon.ico' 599 + }); 600 + } 601 + } 602 + 603 + // Request notification permission 604 + if ("Notification" in window && Notification.permission === "default") { 605 + Notification.requestPermission(); 606 + } 607 + 608 + function setPreset(preset) { 609 + const aircraftInput = document.getElementById('aircraftTypes'); 610 + const airlinesInput = document.getElementById('airlines'); 611 + 612 + switch(preset) { 613 + case 'military': 614 + aircraftInput.value = 'UH, AH, CH, F16, F18, B52, C130, C17, KC135'; 615 + airlinesInput.value = 'RCH, REACH'; 616 + break; 617 + case 'commercial': 618 + aircraftInput.value = 'B737, B747, B777, A320, A330, A350, CRJ, E175'; 619 + airlinesInput.value = 'UAL, AAL, DAL, SWA, JBU'; 620 + break; 621 + case 'ga': 622 + aircraftInput.value = 'C172, C182, PA28, SR22, DA40, PC12'; 623 + airlinesInput.value = ''; 624 + break; 625 + } 626 + 627 + document.getElementById('origins').value = ''; 628 + document.getElementById('destinations').value = ''; 629 + 630 + // Trigger a new search 631 + checkFlights(); 632 + } 633 + 634 + function clearFilters() { 635 + document.getElementById('aircraftTypes').value = ''; 636 + document.getElementById('airlines').value = ''; 637 + document.getElementById('origins').value = ''; 638 + document.getElementById('destinations').value = ''; 639 + checkFlights(); 640 + } 641 + </script> 642 + </body> 643 + </html>
+551
sandbox/flight-notifier/uv.lock
··· 1 + version = 1 2 + revision = 2 3 + requires-python = ">=3.12" 4 + 5 + [[package]] 6 + name = "annotated-types" 7 + version = "0.7.0" 8 + source = { registry = "https://pypi.org/simple" } 9 + sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } 10 + wheels = [ 11 + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, 12 + ] 13 + 14 + [[package]] 15 + name = "anyio" 16 + version = "4.9.0" 17 + source = { registry = "https://pypi.org/simple" } 18 + dependencies = [ 19 + { name = "idna" }, 20 + { name = "sniffio" }, 21 + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, 22 + ] 23 + sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949, upload-time = "2025-03-17T00:02:54.77Z" } 24 + wheels = [ 25 + { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, 26 + ] 27 + 28 + [[package]] 29 + name = "atproto" 30 + version = "0.0.61" 31 + source = { registry = "https://pypi.org/simple" } 32 + dependencies = [ 33 + { name = "click" }, 34 + { name = "cryptography" }, 35 + { name = "dnspython" }, 36 + { name = "httpx" }, 37 + { name = "libipld" }, 38 + { name = "pydantic" }, 39 + { name = "typing-extensions" }, 40 + { name = "websockets" }, 41 + ] 42 + sdist = { url = "https://files.pythonhosted.org/packages/b1/59/6f5074b3a45e0e3c1853544240e9039e86219feb30ff1bb5e8582c791547/atproto-0.0.61.tar.gz", hash = "sha256:98e022daf538d14f134ce7c91d42c4c973f3493ac56e43a84daa4c881f102beb", size = 189208, upload-time = "2025-04-19T00:20:11.918Z" } 43 + wheels = [ 44 + { url = "https://files.pythonhosted.org/packages/bd/b6/da9963bf54d4c0a8a590b6297d8858c395243dbb04cb581fdadb5fe7eac7/atproto-0.0.61-py3-none-any.whl", hash = "sha256:658da5832aaeea4a12a9a74235f9c90c11453e77d596fdccb1f8b39d56245b88", size = 380426, upload-time = "2025-04-19T00:20:10.026Z" }, 45 + ] 46 + 47 + [[package]] 48 + name = "certifi" 49 + version = "2025.8.3" 50 + source = { registry = "https://pypi.org/simple" } 51 + sdist = { url = "https://files.pythonhosted.org/packages/dc/67/960ebe6bf230a96cda2e0abcf73af550ec4f090005363542f0765df162e0/certifi-2025.8.3.tar.gz", hash = "sha256:e564105f78ded564e3ae7c923924435e1daa7463faeab5bb932bc53ffae63407", size = 162386, upload-time = "2025-08-03T03:07:47.08Z" } 52 + wheels = [ 53 + { url = "https://files.pythonhosted.org/packages/e5/48/1549795ba7742c948d2ad169c1c8cdbae65bc450d6cd753d124b17c8cd32/certifi-2025.8.3-py3-none-any.whl", hash = "sha256:f6c12493cfb1b06ba2ff328595af9350c65d6644968e5d3a2ffd78699af217a5", size = 161216, upload-time = "2025-08-03T03:07:45.777Z" }, 54 + ] 55 + 56 + [[package]] 57 + name = "cffi" 58 + version = "1.17.1" 59 + source = { registry = "https://pypi.org/simple" } 60 + dependencies = [ 61 + { name = "pycparser" }, 62 + ] 63 + 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" } 64 + wheels = [ 65 + { 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" }, 66 + { 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" }, 67 + { 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" }, 68 + { 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" }, 69 + { 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" }, 70 + { 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" }, 71 + { 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" }, 72 + { 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" }, 73 + { 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" }, 74 + { 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" }, 75 + { 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" }, 76 + { 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" }, 77 + { 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" }, 78 + { 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" }, 79 + { 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" }, 80 + { 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" }, 81 + { 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" }, 82 + { 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" }, 83 + { 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" }, 84 + { 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" }, 85 + { 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" }, 86 + { 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" }, 87 + ] 88 + 89 + [[package]] 90 + name = "click" 91 + version = "8.2.1" 92 + source = { registry = "https://pypi.org/simple" } 93 + dependencies = [ 94 + { name = "colorama", marker = "sys_platform == 'win32'" }, 95 + ] 96 + sdist = { url = "https://files.pythonhosted.org/packages/60/6c/8ca2efa64cf75a977a0d7fac081354553ebe483345c734fb6b6515d96bbc/click-8.2.1.tar.gz", hash = "sha256:27c491cc05d968d271d5a1db13e3b5a184636d9d930f148c50b038f0d0646202", size = 286342, upload-time = "2025-05-20T23:19:49.832Z" } 97 + wheels = [ 98 + { url = "https://files.pythonhosted.org/packages/85/32/10bb5764d90a8eee674e9dc6f4db6a0ab47c8c4d0d83c27f7c39ac415a4d/click-8.2.1-py3-none-any.whl", hash = "sha256:61a3265b914e850b85317d0b3109c7f8cd35a670f963866005d6ef1d5175a12b", size = 102215, upload-time = "2025-05-20T23:19:47.796Z" }, 99 + ] 100 + 101 + [[package]] 102 + name = "colorama" 103 + version = "0.4.6" 104 + source = { registry = "https://pypi.org/simple" } 105 + sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } 106 + wheels = [ 107 + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, 108 + ] 109 + 110 + [[package]] 111 + name = "cryptography" 112 + version = "45.0.5" 113 + source = { registry = "https://pypi.org/simple" } 114 + dependencies = [ 115 + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, 116 + ] 117 + sdist = { url = "https://files.pythonhosted.org/packages/95/1e/49527ac611af559665f71cbb8f92b332b5ec9c6fbc4e88b0f8e92f5e85df/cryptography-45.0.5.tar.gz", hash = "sha256:72e76caa004ab63accdf26023fccd1d087f6d90ec6048ff33ad0445abf7f605a", size = 744903, upload-time = "2025-07-02T13:06:25.941Z" } 118 + wheels = [ 119 + { url = "https://files.pythonhosted.org/packages/f0/fb/09e28bc0c46d2c547085e60897fea96310574c70fb21cd58a730a45f3403/cryptography-45.0.5-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:101ee65078f6dd3e5a028d4f19c07ffa4dd22cce6a20eaa160f8b5219911e7d8", size = 7043092, upload-time = "2025-07-02T13:05:01.514Z" }, 120 + { url = "https://files.pythonhosted.org/packages/b1/05/2194432935e29b91fb649f6149c1a4f9e6d3d9fc880919f4ad1bcc22641e/cryptography-45.0.5-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3a264aae5f7fbb089dbc01e0242d3b67dffe3e6292e1f5182122bdf58e65215d", size = 4205926, upload-time = "2025-07-02T13:05:04.741Z" }, 121 + { url = "https://files.pythonhosted.org/packages/07/8b/9ef5da82350175e32de245646b1884fc01124f53eb31164c77f95a08d682/cryptography-45.0.5-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e74d30ec9c7cb2f404af331d5b4099a9b322a8a6b25c4632755c8757345baac5", size = 4429235, upload-time = "2025-07-02T13:05:07.084Z" }, 122 + { url = "https://files.pythonhosted.org/packages/7c/e1/c809f398adde1994ee53438912192d92a1d0fc0f2d7582659d9ef4c28b0c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:3af26738f2db354aafe492fb3869e955b12b2ef2e16908c8b9cb928128d42c57", size = 4209785, upload-time = "2025-07-02T13:05:09.321Z" }, 123 + { url = "https://files.pythonhosted.org/packages/d0/8b/07eb6bd5acff58406c5e806eff34a124936f41a4fb52909ffa4d00815f8c/cryptography-45.0.5-cp311-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e6c00130ed423201c5bc5544c23359141660b07999ad82e34e7bb8f882bb78e0", size = 3893050, upload-time = "2025-07-02T13:05:11.069Z" }, 124 + { url = "https://files.pythonhosted.org/packages/ec/ef/3333295ed58d900a13c92806b67e62f27876845a9a908c939f040887cca9/cryptography-45.0.5-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:dd420e577921c8c2d31289536c386aaa30140b473835e97f83bc71ea9d2baf2d", size = 4457379, upload-time = "2025-07-02T13:05:13.32Z" }, 125 + { url = "https://files.pythonhosted.org/packages/d9/9d/44080674dee514dbb82b21d6fa5d1055368f208304e2ab1828d85c9de8f4/cryptography-45.0.5-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:d05a38884db2ba215218745f0781775806bde4f32e07b135348355fe8e4991d9", size = 4209355, upload-time = "2025-07-02T13:05:15.017Z" }, 126 + { url = "https://files.pythonhosted.org/packages/c9/d8/0749f7d39f53f8258e5c18a93131919ac465ee1f9dccaf1b3f420235e0b5/cryptography-45.0.5-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:ad0caded895a00261a5b4aa9af828baede54638754b51955a0ac75576b831b27", size = 4456087, upload-time = "2025-07-02T13:05:16.945Z" }, 127 + { url = "https://files.pythonhosted.org/packages/09/d7/92acac187387bf08902b0bf0699816f08553927bdd6ba3654da0010289b4/cryptography-45.0.5-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9024beb59aca9d31d36fcdc1604dd9bbeed0a55bface9f1908df19178e2f116e", size = 4332873, upload-time = "2025-07-02T13:05:18.743Z" }, 128 + { url = "https://files.pythonhosted.org/packages/03/c2/840e0710da5106a7c3d4153c7215b2736151bba60bf4491bdb421df5056d/cryptography-45.0.5-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:91098f02ca81579c85f66df8a588c78f331ca19089763d733e34ad359f474174", size = 4564651, upload-time = "2025-07-02T13:05:21.382Z" }, 129 + { url = "https://files.pythonhosted.org/packages/2e/92/cc723dd6d71e9747a887b94eb3827825c6c24b9e6ce2bb33b847d31d5eaa/cryptography-45.0.5-cp311-abi3-win32.whl", hash = "sha256:926c3ea71a6043921050eaa639137e13dbe7b4ab25800932a8498364fc1abec9", size = 2929050, upload-time = "2025-07-02T13:05:23.39Z" }, 130 + { url = "https://files.pythonhosted.org/packages/1f/10/197da38a5911a48dd5389c043de4aec4b3c94cb836299b01253940788d78/cryptography-45.0.5-cp311-abi3-win_amd64.whl", hash = "sha256:b85980d1e345fe769cfc57c57db2b59cff5464ee0c045d52c0df087e926fbe63", size = 3403224, upload-time = "2025-07-02T13:05:25.202Z" }, 131 + { url = "https://files.pythonhosted.org/packages/fe/2b/160ce8c2765e7a481ce57d55eba1546148583e7b6f85514472b1d151711d/cryptography-45.0.5-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:f3562c2f23c612f2e4a6964a61d942f891d29ee320edb62ff48ffb99f3de9ae8", size = 7017143, upload-time = "2025-07-02T13:05:27.229Z" }, 132 + { url = "https://files.pythonhosted.org/packages/c2/e7/2187be2f871c0221a81f55ee3105d3cf3e273c0a0853651d7011eada0d7e/cryptography-45.0.5-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3fcfbefc4a7f332dece7272a88e410f611e79458fab97b5efe14e54fe476f4fd", size = 4197780, upload-time = "2025-07-02T13:05:29.299Z" }, 133 + { url = "https://files.pythonhosted.org/packages/b9/cf/84210c447c06104e6be9122661159ad4ce7a8190011669afceeaea150524/cryptography-45.0.5-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:460f8c39ba66af7db0545a8c6f2eabcbc5a5528fc1cf6c3fa9a1e44cec33385e", size = 4420091, upload-time = "2025-07-02T13:05:31.221Z" }, 134 + { url = "https://files.pythonhosted.org/packages/3e/6a/cb8b5c8bb82fafffa23aeff8d3a39822593cee6e2f16c5ca5c2ecca344f7/cryptography-45.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9b4cf6318915dccfe218e69bbec417fdd7c7185aa7aab139a2c0beb7468c89f0", size = 4198711, upload-time = "2025-07-02T13:05:33.062Z" }, 135 + { url = "https://files.pythonhosted.org/packages/04/f7/36d2d69df69c94cbb2473871926daf0f01ad8e00fe3986ac3c1e8c4ca4b3/cryptography-45.0.5-cp37-abi3-manylinux_2_28_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2089cc8f70a6e454601525e5bf2779e665d7865af002a5dec8d14e561002e135", size = 3883299, upload-time = "2025-07-02T13:05:34.94Z" }, 136 + { url = "https://files.pythonhosted.org/packages/82/c7/f0ea40f016de72f81288e9fe8d1f6748036cb5ba6118774317a3ffc6022d/cryptography-45.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0027d566d65a38497bc37e0dd7c2f8ceda73597d2ac9ba93810204f56f52ebc7", size = 4450558, upload-time = "2025-07-02T13:05:37.288Z" }, 137 + { url = "https://files.pythonhosted.org/packages/06/ae/94b504dc1a3cdf642d710407c62e86296f7da9e66f27ab12a1ee6fdf005b/cryptography-45.0.5-cp37-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:be97d3a19c16a9be00edf79dca949c8fa7eff621763666a145f9f9535a5d7f42", size = 4198020, upload-time = "2025-07-02T13:05:39.102Z" }, 138 + { url = "https://files.pythonhosted.org/packages/05/2b/aaf0adb845d5dabb43480f18f7ca72e94f92c280aa983ddbd0bcd6ecd037/cryptography-45.0.5-cp37-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:7760c1c2e1a7084153a0f68fab76e754083b126a47d0117c9ed15e69e2103492", size = 4449759, upload-time = "2025-07-02T13:05:41.398Z" }, 139 + { url = "https://files.pythonhosted.org/packages/91/e4/f17e02066de63e0100a3a01b56f8f1016973a1d67551beaf585157a86b3f/cryptography-45.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:6ff8728d8d890b3dda5765276d1bc6fb099252915a2cd3aff960c4c195745dd0", size = 4319991, upload-time = "2025-07-02T13:05:43.64Z" }, 140 + { url = "https://files.pythonhosted.org/packages/f2/2e/e2dbd629481b499b14516eed933f3276eb3239f7cee2dcfa4ee6b44d4711/cryptography-45.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7259038202a47fdecee7e62e0fd0b0738b6daa335354396c6ddebdbe1206af2a", size = 4554189, upload-time = "2025-07-02T13:05:46.045Z" }, 141 + { url = "https://files.pythonhosted.org/packages/f8/ea/a78a0c38f4c8736287b71c2ea3799d173d5ce778c7d6e3c163a95a05ad2a/cryptography-45.0.5-cp37-abi3-win32.whl", hash = "sha256:1e1da5accc0c750056c556a93c3e9cb828970206c68867712ca5805e46dc806f", size = 2911769, upload-time = "2025-07-02T13:05:48.329Z" }, 142 + { url = "https://files.pythonhosted.org/packages/79/b3/28ac139109d9005ad3f6b6f8976ffede6706a6478e21c889ce36c840918e/cryptography-45.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:90cb0a7bb35959f37e23303b7eed0a32280510030daba3f7fdfbb65defde6a97", size = 3390016, upload-time = "2025-07-02T13:05:50.811Z" }, 143 + ] 144 + 145 + [[package]] 146 + name = "dnspython" 147 + version = "2.7.0" 148 + source = { registry = "https://pypi.org/simple" } 149 + sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197, upload-time = "2024-10-05T20:14:59.362Z" } 150 + wheels = [ 151 + { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632, upload-time = "2024-10-05T20:14:57.687Z" }, 152 + ] 153 + 154 + [[package]] 155 + name = "fastapi" 156 + version = "0.116.1" 157 + source = { registry = "https://pypi.org/simple" } 158 + dependencies = [ 159 + { name = "pydantic" }, 160 + { name = "starlette" }, 161 + { name = "typing-extensions" }, 162 + ] 163 + sdist = { url = "https://files.pythonhosted.org/packages/78/d7/6c8b3bfe33eeffa208183ec037fee0cce9f7f024089ab1c5d12ef04bd27c/fastapi-0.116.1.tar.gz", hash = "sha256:ed52cbf946abfd70c5a0dccb24673f0670deeb517a88b3544d03c2a6bf283143", size = 296485, upload-time = "2025-07-11T16:22:32.057Z" } 164 + wheels = [ 165 + { url = "https://files.pythonhosted.org/packages/e5/47/d63c60f59a59467fda0f93f46335c9d18526d7071f025cb5b89d5353ea42/fastapi-0.116.1-py3-none-any.whl", hash = "sha256:c46ac7c312df840f0c9e220f7964bada936781bc4e2e6eb71f1c4d7553786565", size = 95631, upload-time = "2025-07-11T16:22:30.485Z" }, 166 + ] 167 + 168 + [[package]] 169 + name = "flight-notifier" 170 + version = "0.1.0" 171 + source = { editable = "." } 172 + dependencies = [ 173 + { name = "atproto" }, 174 + { name = "fastapi" }, 175 + { name = "geopy" }, 176 + { name = "httpx" }, 177 + { name = "jinja2" }, 178 + { name = "pydantic-settings" }, 179 + { name = "uvicorn" }, 180 + ] 181 + 182 + [package.dev-dependencies] 183 + dev = [ 184 + { name = "ruff" }, 185 + ] 186 + 187 + [package.metadata] 188 + requires-dist = [ 189 + { name = "atproto" }, 190 + { name = "fastapi" }, 191 + { name = "geopy" }, 192 + { name = "httpx" }, 193 + { name = "jinja2" }, 194 + { name = "pydantic-settings" }, 195 + { name = "uvicorn" }, 196 + ] 197 + 198 + [package.metadata.requires-dev] 199 + dev = [{ name = "ruff" }] 200 + 201 + [[package]] 202 + name = "geographiclib" 203 + version = "2.0" 204 + source = { registry = "https://pypi.org/simple" } 205 + sdist = { url = "https://files.pythonhosted.org/packages/96/cd/90271fd195d79a9c2af0ca21632b297a6cc3e852e0413a2e4519e67be213/geographiclib-2.0.tar.gz", hash = "sha256:f7f41c85dc3e1c2d3d935ec86660dc3b2c848c83e17f9a9e51ba9d5146a15859", size = 36720, upload-time = "2022-04-23T13:01:11.495Z" } 206 + wheels = [ 207 + { url = "https://files.pythonhosted.org/packages/9f/5a/a26132406f1f40cf51ea349a5f11b0a46cec02a2031ff82e391c2537247a/geographiclib-2.0-py3-none-any.whl", hash = "sha256:6b7225248e45ff7edcee32becc4e0a1504c606ac5ee163a5656d482e0cd38734", size = 40324, upload-time = "2022-04-23T13:01:09.958Z" }, 208 + ] 209 + 210 + [[package]] 211 + name = "geopy" 212 + version = "2.4.1" 213 + source = { registry = "https://pypi.org/simple" } 214 + dependencies = [ 215 + { name = "geographiclib" }, 216 + ] 217 + sdist = { url = "https://files.pythonhosted.org/packages/0e/fd/ef6d53875ceab72c1fad22dbed5ec1ad04eb378c2251a6a8024bad890c3b/geopy-2.4.1.tar.gz", hash = "sha256:50283d8e7ad07d89be5cb027338c6365a32044df3ae2556ad3f52f4840b3d0d1", size = 117625, upload-time = "2023-11-23T21:49:32.734Z" } 218 + wheels = [ 219 + { url = "https://files.pythonhosted.org/packages/e5/15/cf2a69ade4b194aa524ac75112d5caac37414b20a3a03e6865dfe0bd1539/geopy-2.4.1-py3-none-any.whl", hash = "sha256:ae8b4bc5c1131820f4d75fce9d4aaaca0c85189b3aa5d64c3dcaf5e3b7b882a7", size = 125437, upload-time = "2023-11-23T21:49:30.421Z" }, 220 + ] 221 + 222 + [[package]] 223 + name = "h11" 224 + version = "0.16.0" 225 + source = { registry = "https://pypi.org/simple" } 226 + sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } 227 + wheels = [ 228 + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, 229 + ] 230 + 231 + [[package]] 232 + name = "httpcore" 233 + version = "1.0.9" 234 + source = { registry = "https://pypi.org/simple" } 235 + dependencies = [ 236 + { name = "certifi" }, 237 + { name = "h11" }, 238 + ] 239 + sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } 240 + wheels = [ 241 + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, 242 + ] 243 + 244 + [[package]] 245 + name = "httpx" 246 + version = "0.28.1" 247 + source = { registry = "https://pypi.org/simple" } 248 + dependencies = [ 249 + { name = "anyio" }, 250 + { name = "certifi" }, 251 + { name = "httpcore" }, 252 + { name = "idna" }, 253 + ] 254 + sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } 255 + wheels = [ 256 + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, 257 + ] 258 + 259 + [[package]] 260 + name = "idna" 261 + version = "3.10" 262 + source = { registry = "https://pypi.org/simple" } 263 + sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490, upload-time = "2024-09-15T18:07:39.745Z" } 264 + wheels = [ 265 + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442, upload-time = "2024-09-15T18:07:37.964Z" }, 266 + ] 267 + 268 + [[package]] 269 + name = "jinja2" 270 + version = "3.1.6" 271 + source = { registry = "https://pypi.org/simple" } 272 + dependencies = [ 273 + { name = "markupsafe" }, 274 + ] 275 + sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" } 276 + wheels = [ 277 + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" }, 278 + ] 279 + 280 + [[package]] 281 + name = "libipld" 282 + version = "3.1.1" 283 + source = { registry = "https://pypi.org/simple" } 284 + sdist = { url = "https://files.pythonhosted.org/packages/84/ac/21f2b0f9848c9d99a87e3cc626e7af0fc24883911ec5d7578686cc2a09d1/libipld-3.1.1.tar.gz", hash = "sha256:4b9a9da0ea5d848e9fa12c700027619a1e37ecc1da39dbd1424c0e9062f29e44", size = 4380425, upload-time = "2025-06-24T23:12:51.395Z" } 285 + wheels = [ 286 + { url = "https://files.pythonhosted.org/packages/fe/07/975b9dde7e27489218c21db4357bd852cd71c388c06abedcff2b86a500ab/libipld-3.1.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:27d2fb2b19a9784a932a41fd1a6942361cfa65e0957871f4bde06c81639a32b1", size = 279659, upload-time = "2025-06-24T23:11:29.139Z" }, 287 + { url = "https://files.pythonhosted.org/packages/4d/db/bd6a9eefa7c90f23ea2ea98678e8f6aac15fedb9645ddaa8af977bcfdf2f/libipld-3.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f0156a9bf04b7f575b907b7a15b902dde2d8af129aeb161b3ab6940f3fd9c02", size = 276397, upload-time = "2025-06-24T23:11:30.54Z" }, 288 + { url = "https://files.pythonhosted.org/packages/02/a8/09606bc7139173d8543cf8206b3c7ff9238bd4c9b47a71565c50912f0323/libipld-3.1.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29cf371122648a688f87fe3307bcfe2c6a4aefa184ba44126f066975cfd26b46", size = 297682, upload-time = "2025-06-24T23:11:31.833Z" }, 289 + { url = "https://files.pythonhosted.org/packages/31/ad/a54d62baead5aecc9a2f48ab2b8ac81fbeb8df19c89416735387dd041175/libipld-3.1.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a5463672cd0708d47bc8cfe1cc0dd95c55d5b7f3947027e0e9c6a13b1dc1b6d0", size = 304615, upload-time = "2025-06-24T23:11:32.8Z" }, 290 + { url = "https://files.pythonhosted.org/packages/c5/a2/3c7908d6aa865721e7e9c2f125e315614cee4e4ced4457d7b22cc8d8acc4/libipld-3.1.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:27a1b9b9392679fb494214bfa350adf7447b43bc39e497b669307da1f6dc8dd5", size = 332042, upload-time = "2025-06-24T23:11:33.831Z" }, 291 + { url = "https://files.pythonhosted.org/packages/e1/c0/ecd838e32630439ca3d8ce2274db32c77f31d0265c01b6a3c00fd96367bb/libipld-3.1.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a83d944c71ed50772a7cef3f14e3ef3cf93145c82963b9e49a85cd9ee0ba9878", size = 344326, upload-time = "2025-06-24T23:11:34.768Z" }, 292 + { url = "https://files.pythonhosted.org/packages/98/79/9ef27cd284c66e7e9481e7fe529d1412ea751b4cad1578571bbc02826098/libipld-3.1.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cb9fef573406f7134727e0561e42fd221721800ed01d47f1207916595b72e780", size = 299195, upload-time = "2025-06-24T23:11:35.973Z" }, 293 + { url = "https://files.pythonhosted.org/packages/a7/6e/2db9510cdc410b154169438449277637f35bbc571c330d60d262320e6d77/libipld-3.1.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:485b21bdddbe7a3bb8f33f1d0b9998343bd82a578406e31f85899b031602d34d", size = 323946, upload-time = "2025-06-24T23:11:37.815Z" }, 294 + { url = "https://files.pythonhosted.org/packages/63/fb/ac59473cbc7598db0e194b2b14b10953029813f204555e5c12405b265594/libipld-3.1.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:4fe6fa67a242755773f3e960163010bdbc797316ca782d387e6b128e0d3bca19", size = 477366, upload-time = "2025-06-24T23:11:38.798Z" }, 295 + { url = "https://files.pythonhosted.org/packages/f5/75/80915af5dc04785ff7a9468529a96d787723d24a9e76dbc31e0141bbcd23/libipld-3.1.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:38298cbea4f8308bb848c7f8c3d8e41cd2c9235ef8bca6adefd2a002e94287ff", size = 470106, upload-time = "2025-06-24T23:11:39.786Z" }, 296 + { url = "https://files.pythonhosted.org/packages/9e/17/832f1c91938a0e2d58905e86c7a2f21cd4b6334a3757221563bd9a8beb64/libipld-3.1.1-cp312-cp312-win32.whl", hash = "sha256:1bc228298e249baac85f702da7d1e23ee429529a078a6bdf09570168f53fcb0f", size = 173435, upload-time = "2025-06-24T23:11:41.072Z" }, 297 + { url = "https://files.pythonhosted.org/packages/14/62/1006fa794c6fe18040d06cebe2d593c20208c2a16a5eb01f7d4f48a5a3b5/libipld-3.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:a393e1809c7b1aa67c6f6c5d701787298f507448a601b8ec825b6ae26084fbad", size = 179271, upload-time = "2025-06-24T23:11:42.155Z" }, 298 + { url = "https://files.pythonhosted.org/packages/bc/af/95b2673bd8ab8225a374bde34b4ac21ef9a725c910517e0dadc5ce26d4a7/libipld-3.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7ad7870d2ee609d74eec4ba6dbc2caef0357861b3e0944226272f0e91f016d37", size = 169727, upload-time = "2025-06-24T23:11:43.164Z" }, 299 + { url = "https://files.pythonhosted.org/packages/e5/25/52f27b9617efb0c2f60e71bbfd4f88167ca7acd3aed413999f16e22b3e54/libipld-3.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:8cd7d7b8b2e0a6ab273b697259f291edbd7cb1b9200ed746a41dcd63fb52017a", size = 280260, upload-time = "2025-06-24T23:11:44.376Z" }, 300 + { url = "https://files.pythonhosted.org/packages/bb/14/123450261a35e869732ff610580df39a62164d9e0aab58334c182c9453f8/libipld-3.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0251c6daa8eceee2ce7dc4f03422f3f1acdd31b04ebda39cab5f8af3dae30943", size = 276684, upload-time = "2025-06-24T23:11:45.266Z" }, 301 + { url = "https://files.pythonhosted.org/packages/bd/3e/6dd2daf43ff735a3f53cbeaeac1edb3ba92fa2e48c64257800ede82442e6/libipld-3.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4d4598b094286998f770f383eedbfc04c1018ec8ebe6746db0eff5b2059a484a", size = 297845, upload-time = "2025-06-24T23:11:46.143Z" }, 302 + { url = "https://files.pythonhosted.org/packages/83/23/e4f89d9bf854c58a5d6e2f2c667425669ed795956003b28de429b0740e0f/libipld-3.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7212411cbce495dfae24c2b6757a5c2f921797fe70ec0c026e1a2d19ae29e59a", size = 305200, upload-time = "2025-06-24T23:11:47.128Z" }, 303 + { url = "https://files.pythonhosted.org/packages/40/43/0b1e871275502e9799589d03a139730c0dfbb36d1922ab213b105ace59ee/libipld-3.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ffc2f978adda8a8309b55510ceda9fe5dc2431d4ff202ff77d84eb57c77d072f", size = 332153, upload-time = "2025-06-24T23:11:48.437Z" }, 304 + { url = "https://files.pythonhosted.org/packages/94/18/5e9cff31d9450e98cc7b4025d1c90bde661ee099ea46cfcb1d8a893e6083/libipld-3.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:99163cc7822abcb028c55860e5341c77200a3ae90f4c158c27e2118a07e8809d", size = 344391, upload-time = "2025-06-24T23:11:49.786Z" }, 305 + { url = "https://files.pythonhosted.org/packages/63/ca/4d938862912ab2f105710d1cc909ec65c71d0e63a90e3b494920c23a4383/libipld-3.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80f142cbd4fa89ef514a4dd43afbd4ed3c33ae7061f0e1e0763f7c1811dea389", size = 299448, upload-time = "2025-06-24T23:11:50.723Z" }, 306 + { url = "https://files.pythonhosted.org/packages/2a/08/f6020e53abe4c26d57fe29b001ba1a84b5b3ad2d618e135b82877e42b59a/libipld-3.1.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4596a6e2c5e81b14b1432f3a6120b1d785fc4f74402cef39accf0041999905e4", size = 324096, upload-time = "2025-06-24T23:11:51.646Z" }, 307 + { url = "https://files.pythonhosted.org/packages/df/0f/d3d9da8f1001e9856bc5cb171a838ca5102da7d959b870a0c5f5aa9ef82e/libipld-3.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:0cd275603ab3cc2394d40455de6976f01b2d85b4095c074c0c1e2692013f5eaa", size = 477593, upload-time = "2025-06-24T23:11:52.565Z" }, 308 + { url = "https://files.pythonhosted.org/packages/59/df/57dcd84e55c02f74bb40a246dd849430994bbb476e91b05179d749993c9a/libipld-3.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:16c999b3af996865004ff2da8280d0c24b672d8a00f9e4cd3a468da8f5e63a5a", size = 470201, upload-time = "2025-06-24T23:11:53.544Z" }, 309 + { url = "https://files.pythonhosted.org/packages/80/af/aee0800b415b63dc5e259675c31a36d6c261afff8e288b56bc2867aa9310/libipld-3.1.1-cp313-cp313-win32.whl", hash = "sha256:5d34c40a27e8755f500277be5268a2f6b6f0d1e20599152d8a34cd34fb3f2700", size = 173730, upload-time = "2025-06-24T23:11:54.5Z" }, 310 + { url = "https://files.pythonhosted.org/packages/54/a3/7e447f27ee896f48332254bb38e1b6c1d3f24b13e5029977646de9408159/libipld-3.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:5edee5f2ea8183bb6a151f149c9798a4f1db69fe16307e860a84f8d41b53665a", size = 179409, upload-time = "2025-06-24T23:11:55.356Z" }, 311 + { url = "https://files.pythonhosted.org/packages/f2/0b/31d6097620c5cfaaaa0acb7760c29186029cd72c6ab81c537cc1ddfb34e5/libipld-3.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:7307876987d9e570dcaf17a15f0ba210f678b323860742d725cf6d8d8baeae1f", size = 169715, upload-time = "2025-06-24T23:11:56.41Z" }, 312 + ] 313 + 314 + [[package]] 315 + name = "markupsafe" 316 + version = "3.0.2" 317 + source = { registry = "https://pypi.org/simple" } 318 + sdist = { url = "https://files.pythonhosted.org/packages/b2/97/5d42485e71dfc078108a86d6de8fa46db44a1a9295e89c5d6d4a06e23a62/markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0", size = 20537, upload-time = "2024-10-18T15:21:54.129Z" } 319 + wheels = [ 320 + { url = "https://files.pythonhosted.org/packages/22/09/d1f21434c97fc42f09d290cbb6350d44eb12f09cc62c9476effdb33a18aa/MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf", size = 14274, upload-time = "2024-10-18T15:21:13.777Z" }, 321 + { url = "https://files.pythonhosted.org/packages/6b/b0/18f76bba336fa5aecf79d45dcd6c806c280ec44538b3c13671d49099fdd0/MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225", size = 12348, upload-time = "2024-10-18T15:21:14.822Z" }, 322 + { url = "https://files.pythonhosted.org/packages/e0/25/dd5c0f6ac1311e9b40f4af06c78efde0f3b5cbf02502f8ef9501294c425b/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028", size = 24149, upload-time = "2024-10-18T15:21:15.642Z" }, 323 + { url = "https://files.pythonhosted.org/packages/f3/f0/89e7aadfb3749d0f52234a0c8c7867877876e0a20b60e2188e9850794c17/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8", size = 23118, upload-time = "2024-10-18T15:21:17.133Z" }, 324 + { url = "https://files.pythonhosted.org/packages/d5/da/f2eeb64c723f5e3777bc081da884b414671982008c47dcc1873d81f625b6/MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c", size = 22993, upload-time = "2024-10-18T15:21:18.064Z" }, 325 + { url = "https://files.pythonhosted.org/packages/da/0e/1f32af846df486dce7c227fe0f2398dc7e2e51d4a370508281f3c1c5cddc/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557", size = 24178, upload-time = "2024-10-18T15:21:18.859Z" }, 326 + { url = "https://files.pythonhosted.org/packages/c4/f6/bb3ca0532de8086cbff5f06d137064c8410d10779c4c127e0e47d17c0b71/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22", size = 23319, upload-time = "2024-10-18T15:21:19.671Z" }, 327 + { url = "https://files.pythonhosted.org/packages/a2/82/8be4c96ffee03c5b4a034e60a31294daf481e12c7c43ab8e34a1453ee48b/MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48", size = 23352, upload-time = "2024-10-18T15:21:20.971Z" }, 328 + { url = "https://files.pythonhosted.org/packages/51/ae/97827349d3fcffee7e184bdf7f41cd6b88d9919c80f0263ba7acd1bbcb18/MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30", size = 15097, upload-time = "2024-10-18T15:21:22.646Z" }, 329 + { url = "https://files.pythonhosted.org/packages/c1/80/a61f99dc3a936413c3ee4e1eecac96c0da5ed07ad56fd975f1a9da5bc630/MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87", size = 15601, upload-time = "2024-10-18T15:21:23.499Z" }, 330 + { url = "https://files.pythonhosted.org/packages/83/0e/67eb10a7ecc77a0c2bbe2b0235765b98d164d81600746914bebada795e97/MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd", size = 14274, upload-time = "2024-10-18T15:21:24.577Z" }, 331 + { url = "https://files.pythonhosted.org/packages/2b/6d/9409f3684d3335375d04e5f05744dfe7e9f120062c9857df4ab490a1031a/MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430", size = 12352, upload-time = "2024-10-18T15:21:25.382Z" }, 332 + { url = "https://files.pythonhosted.org/packages/d2/f5/6eadfcd3885ea85fe2a7c128315cc1bb7241e1987443d78c8fe712d03091/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094", size = 24122, upload-time = "2024-10-18T15:21:26.199Z" }, 333 + { url = "https://files.pythonhosted.org/packages/0c/91/96cf928db8236f1bfab6ce15ad070dfdd02ed88261c2afafd4b43575e9e9/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396", size = 23085, upload-time = "2024-10-18T15:21:27.029Z" }, 334 + { url = "https://files.pythonhosted.org/packages/c2/cf/c9d56af24d56ea04daae7ac0940232d31d5a8354f2b457c6d856b2057d69/MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79", size = 22978, upload-time = "2024-10-18T15:21:27.846Z" }, 335 + { url = "https://files.pythonhosted.org/packages/2a/9f/8619835cd6a711d6272d62abb78c033bda638fdc54c4e7f4272cf1c0962b/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a", size = 24208, upload-time = "2024-10-18T15:21:28.744Z" }, 336 + { url = "https://files.pythonhosted.org/packages/f9/bf/176950a1792b2cd2102b8ffeb5133e1ed984547b75db47c25a67d3359f77/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca", size = 23357, upload-time = "2024-10-18T15:21:29.545Z" }, 337 + { url = "https://files.pythonhosted.org/packages/ce/4f/9a02c1d335caabe5c4efb90e1b6e8ee944aa245c1aaaab8e8a618987d816/MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c", size = 23344, upload-time = "2024-10-18T15:21:30.366Z" }, 338 + { url = "https://files.pythonhosted.org/packages/ee/55/c271b57db36f748f0e04a759ace9f8f759ccf22b4960c270c78a394f58be/MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1", size = 15101, upload-time = "2024-10-18T15:21:31.207Z" }, 339 + { url = "https://files.pythonhosted.org/packages/29/88/07df22d2dd4df40aba9f3e402e6dc1b8ee86297dddbad4872bd5e7b0094f/MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f", size = 15603, upload-time = "2024-10-18T15:21:32.032Z" }, 340 + { url = "https://files.pythonhosted.org/packages/62/6a/8b89d24db2d32d433dffcd6a8779159da109842434f1dd2f6e71f32f738c/MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c", size = 14510, upload-time = "2024-10-18T15:21:33.625Z" }, 341 + { url = "https://files.pythonhosted.org/packages/7a/06/a10f955f70a2e5a9bf78d11a161029d278eeacbd35ef806c3fd17b13060d/MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb", size = 12486, upload-time = "2024-10-18T15:21:34.611Z" }, 342 + { url = "https://files.pythonhosted.org/packages/34/cf/65d4a571869a1a9078198ca28f39fba5fbb910f952f9dbc5220afff9f5e6/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c", size = 25480, upload-time = "2024-10-18T15:21:35.398Z" }, 343 + { url = "https://files.pythonhosted.org/packages/0c/e3/90e9651924c430b885468b56b3d597cabf6d72be4b24a0acd1fa0e12af67/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d", size = 23914, upload-time = "2024-10-18T15:21:36.231Z" }, 344 + { url = "https://files.pythonhosted.org/packages/66/8c/6c7cf61f95d63bb866db39085150df1f2a5bd3335298f14a66b48e92659c/MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe", size = 23796, upload-time = "2024-10-18T15:21:37.073Z" }, 345 + { url = "https://files.pythonhosted.org/packages/bb/35/cbe9238ec3f47ac9a7c8b3df7a808e7cb50fe149dc7039f5f454b3fba218/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5", size = 25473, upload-time = "2024-10-18T15:21:37.932Z" }, 346 + { url = "https://files.pythonhosted.org/packages/e6/32/7621a4382488aa283cc05e8984a9c219abad3bca087be9ec77e89939ded9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a", size = 24114, upload-time = "2024-10-18T15:21:39.799Z" }, 347 + { url = "https://files.pythonhosted.org/packages/0d/80/0985960e4b89922cb5a0bac0ed39c5b96cbc1a536a99f30e8c220a996ed9/MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9", size = 24098, upload-time = "2024-10-18T15:21:40.813Z" }, 348 + { url = "https://files.pythonhosted.org/packages/82/78/fedb03c7d5380df2427038ec8d973587e90561b2d90cd472ce9254cf348b/MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6", size = 15208, upload-time = "2024-10-18T15:21:41.814Z" }, 349 + { url = "https://files.pythonhosted.org/packages/4f/65/6079a46068dfceaeabb5dcad6d674f5f5c61a6fa5673746f42a9f4c233b3/MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f", size = 15739, upload-time = "2024-10-18T15:21:42.784Z" }, 350 + ] 351 + 352 + [[package]] 353 + name = "pycparser" 354 + version = "2.22" 355 + source = { registry = "https://pypi.org/simple" } 356 + 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" } 357 + wheels = [ 358 + { 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" }, 359 + ] 360 + 361 + [[package]] 362 + name = "pydantic" 363 + version = "2.11.7" 364 + source = { registry = "https://pypi.org/simple" } 365 + dependencies = [ 366 + { name = "annotated-types" }, 367 + { name = "pydantic-core" }, 368 + { name = "typing-extensions" }, 369 + { name = "typing-inspection" }, 370 + ] 371 + sdist = { url = "https://files.pythonhosted.org/packages/00/dd/4325abf92c39ba8623b5af936ddb36ffcfe0beae70405d456ab1fb2f5b8c/pydantic-2.11.7.tar.gz", hash = "sha256:d989c3c6cb79469287b1569f7447a17848c998458d49ebe294e975b9baf0f0db", size = 788350, upload-time = "2025-06-14T08:33:17.137Z" } 372 + wheels = [ 373 + { url = "https://files.pythonhosted.org/packages/6a/c0/ec2b1c8712ca690e5d61979dee872603e92b8a32f94cc1b72d53beab008a/pydantic-2.11.7-py3-none-any.whl", hash = "sha256:dde5df002701f6de26248661f6835bbe296a47bf73990135c7d07ce741b9623b", size = 444782, upload-time = "2025-06-14T08:33:14.905Z" }, 374 + ] 375 + 376 + [[package]] 377 + name = "pydantic-core" 378 + version = "2.33.2" 379 + source = { registry = "https://pypi.org/simple" } 380 + dependencies = [ 381 + { name = "typing-extensions" }, 382 + ] 383 + sdist = { url = "https://files.pythonhosted.org/packages/ad/88/5f2260bdfae97aabf98f1778d43f69574390ad787afb646292a638c923d4/pydantic_core-2.33.2.tar.gz", hash = "sha256:7cb8bc3605c29176e1b105350d2e6474142d7c1bd1d9327c4a9bdb46bf827acc", size = 435195, upload-time = "2025-04-23T18:33:52.104Z" } 384 + wheels = [ 385 + { url = "https://files.pythonhosted.org/packages/18/8a/2b41c97f554ec8c71f2a8a5f85cb56a8b0956addfe8b0efb5b3d77e8bdc3/pydantic_core-2.33.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a7ec89dc587667f22b6a0b6579c249fca9026ce7c333fc142ba42411fa243cdc", size = 2009000, upload-time = "2025-04-23T18:31:25.863Z" }, 386 + { url = "https://files.pythonhosted.org/packages/a1/02/6224312aacb3c8ecbaa959897af57181fb6cf3a3d7917fd44d0f2917e6f2/pydantic_core-2.33.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:3c6db6e52c6d70aa0d00d45cdb9b40f0433b96380071ea80b09277dba021ddf7", size = 1847996, upload-time = "2025-04-23T18:31:27.341Z" }, 387 + { url = "https://files.pythonhosted.org/packages/d6/46/6dcdf084a523dbe0a0be59d054734b86a981726f221f4562aed313dbcb49/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e61206137cbc65e6d5256e1166f88331d3b6238e082d9f74613b9b765fb9025", size = 1880957, upload-time = "2025-04-23T18:31:28.956Z" }, 388 + { url = "https://files.pythonhosted.org/packages/ec/6b/1ec2c03837ac00886ba8160ce041ce4e325b41d06a034adbef11339ae422/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eb8c529b2819c37140eb51b914153063d27ed88e3bdc31b71198a198e921e011", size = 1964199, upload-time = "2025-04-23T18:31:31.025Z" }, 389 + { url = "https://files.pythonhosted.org/packages/2d/1d/6bf34d6adb9debd9136bd197ca72642203ce9aaaa85cfcbfcf20f9696e83/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c52b02ad8b4e2cf14ca7b3d918f3eb0ee91e63b3167c32591e57c4317e134f8f", size = 2120296, upload-time = "2025-04-23T18:31:32.514Z" }, 390 + { url = "https://files.pythonhosted.org/packages/e0/94/2bd0aaf5a591e974b32a9f7123f16637776c304471a0ab33cf263cf5591a/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:96081f1605125ba0855dfda83f6f3df5ec90c61195421ba72223de35ccfb2f88", size = 2676109, upload-time = "2025-04-23T18:31:33.958Z" }, 391 + { url = "https://files.pythonhosted.org/packages/f9/41/4b043778cf9c4285d59742281a769eac371b9e47e35f98ad321349cc5d61/pydantic_core-2.33.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f57a69461af2a5fa6e6bbd7a5f60d3b7e6cebb687f55106933188e79ad155c1", size = 2002028, upload-time = "2025-04-23T18:31:39.095Z" }, 392 + { url = "https://files.pythonhosted.org/packages/cb/d5/7bb781bf2748ce3d03af04d5c969fa1308880e1dca35a9bd94e1a96a922e/pydantic_core-2.33.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:572c7e6c8bb4774d2ac88929e3d1f12bc45714ae5ee6d9a788a9fb35e60bb04b", size = 2100044, upload-time = "2025-04-23T18:31:41.034Z" }, 393 + { url = "https://files.pythonhosted.org/packages/fe/36/def5e53e1eb0ad896785702a5bbfd25eed546cdcf4087ad285021a90ed53/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db4b41f9bd95fbe5acd76d89920336ba96f03e149097365afe1cb092fceb89a1", size = 2058881, upload-time = "2025-04-23T18:31:42.757Z" }, 394 + { url = "https://files.pythonhosted.org/packages/01/6c/57f8d70b2ee57fc3dc8b9610315949837fa8c11d86927b9bb044f8705419/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:fa854f5cf7e33842a892e5c73f45327760bc7bc516339fda888c75ae60edaeb6", size = 2227034, upload-time = "2025-04-23T18:31:44.304Z" }, 395 + { url = "https://files.pythonhosted.org/packages/27/b9/9c17f0396a82b3d5cbea4c24d742083422639e7bb1d5bf600e12cb176a13/pydantic_core-2.33.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:5f483cfb75ff703095c59e365360cb73e00185e01aaea067cd19acffd2ab20ea", size = 2234187, upload-time = "2025-04-23T18:31:45.891Z" }, 396 + { url = "https://files.pythonhosted.org/packages/b0/6a/adf5734ffd52bf86d865093ad70b2ce543415e0e356f6cacabbc0d9ad910/pydantic_core-2.33.2-cp312-cp312-win32.whl", hash = "sha256:9cb1da0f5a471435a7bc7e439b8a728e8b61e59784b2af70d7c169f8dd8ae290", size = 1892628, upload-time = "2025-04-23T18:31:47.819Z" }, 397 + { url = "https://files.pythonhosted.org/packages/43/e4/5479fecb3606c1368d496a825d8411e126133c41224c1e7238be58b87d7e/pydantic_core-2.33.2-cp312-cp312-win_amd64.whl", hash = "sha256:f941635f2a3d96b2973e867144fde513665c87f13fe0e193c158ac51bfaaa7b2", size = 1955866, upload-time = "2025-04-23T18:31:49.635Z" }, 398 + { url = "https://files.pythonhosted.org/packages/0d/24/8b11e8b3e2be9dd82df4b11408a67c61bb4dc4f8e11b5b0fc888b38118b5/pydantic_core-2.33.2-cp312-cp312-win_arm64.whl", hash = "sha256:cca3868ddfaccfbc4bfb1d608e2ccaaebe0ae628e1416aeb9c4d88c001bb45ab", size = 1888894, upload-time = "2025-04-23T18:31:51.609Z" }, 399 + { url = "https://files.pythonhosted.org/packages/46/8c/99040727b41f56616573a28771b1bfa08a3d3fe74d3d513f01251f79f172/pydantic_core-2.33.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:1082dd3e2d7109ad8b7da48e1d4710c8d06c253cbc4a27c1cff4fbcaa97a9e3f", size = 2015688, upload-time = "2025-04-23T18:31:53.175Z" }, 400 + { url = "https://files.pythonhosted.org/packages/3a/cc/5999d1eb705a6cefc31f0b4a90e9f7fc400539b1a1030529700cc1b51838/pydantic_core-2.33.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f517ca031dfc037a9c07e748cefd8d96235088b83b4f4ba8939105d20fa1dcd6", size = 1844808, upload-time = "2025-04-23T18:31:54.79Z" }, 401 + { url = "https://files.pythonhosted.org/packages/6f/5e/a0a7b8885c98889a18b6e376f344da1ef323d270b44edf8174d6bce4d622/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0a9f2c9dd19656823cb8250b0724ee9c60a82f3cdf68a080979d13092a3b0fef", size = 1885580, upload-time = "2025-04-23T18:31:57.393Z" }, 402 + { url = "https://files.pythonhosted.org/packages/3b/2a/953581f343c7d11a304581156618c3f592435523dd9d79865903272c256a/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2b0a451c263b01acebe51895bfb0e1cc842a5c666efe06cdf13846c7418caa9a", size = 1973859, upload-time = "2025-04-23T18:31:59.065Z" }, 403 + { url = "https://files.pythonhosted.org/packages/e6/55/f1a813904771c03a3f97f676c62cca0c0a4138654107c1b61f19c644868b/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ea40a64d23faa25e62a70ad163571c0b342b8bf66d5fa612ac0dec4f069d916", size = 2120810, upload-time = "2025-04-23T18:32:00.78Z" }, 404 + { url = "https://files.pythonhosted.org/packages/aa/c3/053389835a996e18853ba107a63caae0b9deb4a276c6b472931ea9ae6e48/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb2d542b4d66f9470e8065c5469ec676978d625a8b7a363f07d9a501a9cb36a", size = 2676498, upload-time = "2025-04-23T18:32:02.418Z" }, 405 + { url = "https://files.pythonhosted.org/packages/eb/3c/f4abd740877a35abade05e437245b192f9d0ffb48bbbbd708df33d3cda37/pydantic_core-2.33.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdac5d6ffa1b5a83bca06ffe7583f5576555e6c8b3a91fbd25ea7780f825f7d", size = 2000611, upload-time = "2025-04-23T18:32:04.152Z" }, 406 + { url = "https://files.pythonhosted.org/packages/59/a7/63ef2fed1837d1121a894d0ce88439fe3e3b3e48c7543b2a4479eb99c2bd/pydantic_core-2.33.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:04a1a413977ab517154eebb2d326da71638271477d6ad87a769102f7c2488c56", size = 2107924, upload-time = "2025-04-23T18:32:06.129Z" }, 407 + { url = "https://files.pythonhosted.org/packages/04/8f/2551964ef045669801675f1cfc3b0d74147f4901c3ffa42be2ddb1f0efc4/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c8e7af2f4e0194c22b5b37205bfb293d166a7344a5b0d0eaccebc376546d77d5", size = 2063196, upload-time = "2025-04-23T18:32:08.178Z" }, 408 + { url = "https://files.pythonhosted.org/packages/26/bd/d9602777e77fc6dbb0c7db9ad356e9a985825547dce5ad1d30ee04903918/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:5c92edd15cd58b3c2d34873597a1e20f13094f59cf88068adb18947df5455b4e", size = 2236389, upload-time = "2025-04-23T18:32:10.242Z" }, 409 + { url = "https://files.pythonhosted.org/packages/42/db/0e950daa7e2230423ab342ae918a794964b053bec24ba8af013fc7c94846/pydantic_core-2.33.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:65132b7b4a1c0beded5e057324b7e16e10910c106d43675d9bd87d4f38dde162", size = 2239223, upload-time = "2025-04-23T18:32:12.382Z" }, 410 + { url = "https://files.pythonhosted.org/packages/58/4d/4f937099c545a8a17eb52cb67fe0447fd9a373b348ccfa9a87f141eeb00f/pydantic_core-2.33.2-cp313-cp313-win32.whl", hash = "sha256:52fb90784e0a242bb96ec53f42196a17278855b0f31ac7c3cc6f5c1ec4811849", size = 1900473, upload-time = "2025-04-23T18:32:14.034Z" }, 411 + { url = "https://files.pythonhosted.org/packages/a0/75/4a0a9bac998d78d889def5e4ef2b065acba8cae8c93696906c3a91f310ca/pydantic_core-2.33.2-cp313-cp313-win_amd64.whl", hash = "sha256:c083a3bdd5a93dfe480f1125926afcdbf2917ae714bdb80b36d34318b2bec5d9", size = 1955269, upload-time = "2025-04-23T18:32:15.783Z" }, 412 + { url = "https://files.pythonhosted.org/packages/f9/86/1beda0576969592f1497b4ce8e7bc8cbdf614c352426271b1b10d5f0aa64/pydantic_core-2.33.2-cp313-cp313-win_arm64.whl", hash = "sha256:e80b087132752f6b3d714f041ccf74403799d3b23a72722ea2e6ba2e892555b9", size = 1893921, upload-time = "2025-04-23T18:32:18.473Z" }, 413 + { url = "https://files.pythonhosted.org/packages/a4/7d/e09391c2eebeab681df2b74bfe6c43422fffede8dc74187b2b0bf6fd7571/pydantic_core-2.33.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:61c18fba8e5e9db3ab908620af374db0ac1baa69f0f32df4f61ae23f15e586ac", size = 1806162, upload-time = "2025-04-23T18:32:20.188Z" }, 414 + { url = "https://files.pythonhosted.org/packages/f1/3d/847b6b1fed9f8ed3bb95a9ad04fbd0b212e832d4f0f50ff4d9ee5a9f15cf/pydantic_core-2.33.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95237e53bb015f67b63c91af7518a62a8660376a6a0db19b89acc77a4d6199f5", size = 1981560, upload-time = "2025-04-23T18:32:22.354Z" }, 415 + { url = "https://files.pythonhosted.org/packages/6f/9a/e73262f6c6656262b5fdd723ad90f518f579b7bc8622e43a942eec53c938/pydantic_core-2.33.2-cp313-cp313t-win_amd64.whl", hash = "sha256:c2fc0a768ef76c15ab9238afa6da7f69895bb5d1ee83aeea2e3509af4472d0b9", size = 1935777, upload-time = "2025-04-23T18:32:25.088Z" }, 416 + ] 417 + 418 + [[package]] 419 + name = "pydantic-settings" 420 + version = "2.10.1" 421 + source = { registry = "https://pypi.org/simple" } 422 + dependencies = [ 423 + { name = "pydantic" }, 424 + { name = "python-dotenv" }, 425 + { name = "typing-inspection" }, 426 + ] 427 + sdist = { url = "https://files.pythonhosted.org/packages/68/85/1ea668bbab3c50071ca613c6ab30047fb36ab0da1b92fa8f17bbc38fd36c/pydantic_settings-2.10.1.tar.gz", hash = "sha256:06f0062169818d0f5524420a360d632d5857b83cffd4d42fe29597807a1614ee", size = 172583, upload-time = "2025-06-24T13:26:46.841Z" } 428 + wheels = [ 429 + { url = "https://files.pythonhosted.org/packages/58/f0/427018098906416f580e3cf1366d3b1abfb408a0652e9f31600c24a1903c/pydantic_settings-2.10.1-py3-none-any.whl", hash = "sha256:a60952460b99cf661dc25c29c0ef171721f98bfcb52ef8d9ea4c943d7c8cc796", size = 45235, upload-time = "2025-06-24T13:26:45.485Z" }, 430 + ] 431 + 432 + [[package]] 433 + name = "python-dotenv" 434 + version = "1.1.1" 435 + source = { registry = "https://pypi.org/simple" } 436 + sdist = { url = "https://files.pythonhosted.org/packages/f6/b0/4bc07ccd3572a2f9df7e6782f52b0c6c90dcbb803ac4a167702d7d0dfe1e/python_dotenv-1.1.1.tar.gz", hash = "sha256:a8a6399716257f45be6a007360200409fce5cda2661e3dec71d23dc15f6189ab", size = 41978, upload-time = "2025-06-24T04:21:07.341Z" } 437 + wheels = [ 438 + { url = "https://files.pythonhosted.org/packages/5f/ed/539768cf28c661b5b068d66d96a2f155c4971a5d55684a514c1a0e0dec2f/python_dotenv-1.1.1-py3-none-any.whl", hash = "sha256:31f23644fe2602f88ff55e1f5c79ba497e01224ee7737937930c448e4d0e24dc", size = 20556, upload-time = "2025-06-24T04:21:06.073Z" }, 439 + ] 440 + 441 + [[package]] 442 + name = "ruff" 443 + version = "0.12.7" 444 + source = { registry = "https://pypi.org/simple" } 445 + sdist = { url = "https://files.pythonhosted.org/packages/a1/81/0bd3594fa0f690466e41bd033bdcdf86cba8288345ac77ad4afbe5ec743a/ruff-0.12.7.tar.gz", hash = "sha256:1fc3193f238bc2d7968772c82831a4ff69252f673be371fb49663f0068b7ec71", size = 5197814, upload-time = "2025-07-29T22:32:35.877Z" } 446 + wheels = [ 447 + { url = "https://files.pythonhosted.org/packages/e1/d2/6cb35e9c85e7a91e8d22ab32ae07ac39cc34a71f1009a6f9e4a2a019e602/ruff-0.12.7-py3-none-linux_armv6l.whl", hash = "sha256:76e4f31529899b8c434c3c1dede98c4483b89590e15fb49f2d46183801565303", size = 11852189, upload-time = "2025-07-29T22:31:41.281Z" }, 448 + { url = "https://files.pythonhosted.org/packages/63/5b/a4136b9921aa84638f1a6be7fb086f8cad0fde538ba76bda3682f2599a2f/ruff-0.12.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:789b7a03e72507c54fb3ba6209e4bb36517b90f1a3569ea17084e3fd295500fb", size = 12519389, upload-time = "2025-07-29T22:31:54.265Z" }, 449 + { url = "https://files.pythonhosted.org/packages/a8/c9/3e24a8472484269b6b1821794141f879c54645a111ded4b6f58f9ab0705f/ruff-0.12.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2e1c2a3b8626339bb6369116e7030a4cf194ea48f49b64bb505732a7fce4f4e3", size = 11743384, upload-time = "2025-07-29T22:31:59.575Z" }, 450 + { url = "https://files.pythonhosted.org/packages/26/7c/458dd25deeb3452c43eaee853c0b17a1e84169f8021a26d500ead77964fd/ruff-0.12.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32dec41817623d388e645612ec70d5757a6d9c035f3744a52c7b195a57e03860", size = 11943759, upload-time = "2025-07-29T22:32:01.95Z" }, 451 + { url = "https://files.pythonhosted.org/packages/7f/8b/658798472ef260ca050e400ab96ef7e85c366c39cf3dfbef4d0a46a528b6/ruff-0.12.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:47ef751f722053a5df5fa48d412dbb54d41ab9b17875c6840a58ec63ff0c247c", size = 11654028, upload-time = "2025-07-29T22:32:04.367Z" }, 452 + { url = "https://files.pythonhosted.org/packages/a8/86/9c2336f13b2a3326d06d39178fd3448dcc7025f82514d1b15816fe42bfe8/ruff-0.12.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a828a5fc25a3efd3e1ff7b241fd392686c9386f20e5ac90aa9234a5faa12c423", size = 13225209, upload-time = "2025-07-29T22:32:06.952Z" }, 453 + { url = "https://files.pythonhosted.org/packages/76/69/df73f65f53d6c463b19b6b312fd2391dc36425d926ec237a7ed028a90fc1/ruff-0.12.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:5726f59b171111fa6a69d82aef48f00b56598b03a22f0f4170664ff4d8298efb", size = 14182353, upload-time = "2025-07-29T22:32:10.053Z" }, 454 + { url = "https://files.pythonhosted.org/packages/58/1e/de6cda406d99fea84b66811c189b5ea139814b98125b052424b55d28a41c/ruff-0.12.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:74e6f5c04c4dd4aba223f4fe6e7104f79e0eebf7d307e4f9b18c18362124bccd", size = 13631555, upload-time = "2025-07-29T22:32:12.644Z" }, 455 + { url = "https://files.pythonhosted.org/packages/6f/ae/625d46d5164a6cc9261945a5e89df24457dc8262539ace3ac36c40f0b51e/ruff-0.12.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5d0bfe4e77fba61bf2ccadf8cf005d6133e3ce08793bbe870dd1c734f2699a3e", size = 12667556, upload-time = "2025-07-29T22:32:15.312Z" }, 456 + { url = "https://files.pythonhosted.org/packages/55/bf/9cb1ea5e3066779e42ade8d0cd3d3b0582a5720a814ae1586f85014656b6/ruff-0.12.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06bfb01e1623bf7f59ea749a841da56f8f653d641bfd046edee32ede7ff6c606", size = 12939784, upload-time = "2025-07-29T22:32:17.69Z" }, 457 + { url = "https://files.pythonhosted.org/packages/55/7f/7ead2663be5627c04be83754c4f3096603bf5e99ed856c7cd29618c691bd/ruff-0.12.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e41df94a957d50083fd09b916d6e89e497246698c3f3d5c681c8b3e7b9bb4ac8", size = 11771356, upload-time = "2025-07-29T22:32:20.134Z" }, 458 + { url = "https://files.pythonhosted.org/packages/17/40/a95352ea16edf78cd3a938085dccc55df692a4d8ba1b3af7accbe2c806b0/ruff-0.12.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4000623300563c709458d0ce170c3d0d788c23a058912f28bbadc6f905d67afa", size = 11612124, upload-time = "2025-07-29T22:32:22.645Z" }, 459 + { url = "https://files.pythonhosted.org/packages/4d/74/633b04871c669e23b8917877e812376827c06df866e1677f15abfadc95cb/ruff-0.12.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:69ffe0e5f9b2cf2b8e289a3f8945b402a1b19eff24ec389f45f23c42a3dd6fb5", size = 12479945, upload-time = "2025-07-29T22:32:24.765Z" }, 460 + { url = "https://files.pythonhosted.org/packages/be/34/c3ef2d7799c9778b835a76189c6f53c179d3bdebc8c65288c29032e03613/ruff-0.12.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a07a5c8ffa2611a52732bdc67bf88e243abd84fe2d7f6daef3826b59abbfeda4", size = 12998677, upload-time = "2025-07-29T22:32:27.022Z" }, 461 + { url = "https://files.pythonhosted.org/packages/77/ab/aca2e756ad7b09b3d662a41773f3edcbd262872a4fc81f920dc1ffa44541/ruff-0.12.7-py3-none-win32.whl", hash = "sha256:c928f1b2ec59fb77dfdf70e0419408898b63998789cc98197e15f560b9e77f77", size = 11756687, upload-time = "2025-07-29T22:32:29.381Z" }, 462 + { url = "https://files.pythonhosted.org/packages/b4/71/26d45a5042bc71db22ddd8252ca9d01e9ca454f230e2996bb04f16d72799/ruff-0.12.7-py3-none-win_amd64.whl", hash = "sha256:9c18f3d707ee9edf89da76131956aba1270c6348bfee8f6c647de841eac7194f", size = 12912365, upload-time = "2025-07-29T22:32:31.517Z" }, 463 + { url = "https://files.pythonhosted.org/packages/4c/9b/0b8aa09817b63e78d94b4977f18b1fcaead3165a5ee49251c5d5c245bb2d/ruff-0.12.7-py3-none-win_arm64.whl", hash = "sha256:dfce05101dbd11833a0776716d5d1578641b7fddb537fe7fa956ab85d1769b69", size = 11982083, upload-time = "2025-07-29T22:32:33.881Z" }, 464 + ] 465 + 466 + [[package]] 467 + name = "sniffio" 468 + version = "1.3.1" 469 + source = { registry = "https://pypi.org/simple" } 470 + sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" } 471 + wheels = [ 472 + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, 473 + ] 474 + 475 + [[package]] 476 + name = "starlette" 477 + version = "0.47.2" 478 + source = { registry = "https://pypi.org/simple" } 479 + dependencies = [ 480 + { name = "anyio" }, 481 + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, 482 + ] 483 + sdist = { url = "https://files.pythonhosted.org/packages/04/57/d062573f391d062710d4088fa1369428c38d51460ab6fedff920efef932e/starlette-0.47.2.tar.gz", hash = "sha256:6ae9aa5db235e4846decc1e7b79c4f346adf41e9777aebeb49dfd09bbd7023d8", size = 2583948, upload-time = "2025-07-20T17:31:58.522Z" } 484 + wheels = [ 485 + { url = "https://files.pythonhosted.org/packages/f7/1f/b876b1f83aef204198a42dc101613fefccb32258e5428b5f9259677864b4/starlette-0.47.2-py3-none-any.whl", hash = "sha256:c5847e96134e5c5371ee9fac6fdf1a67336d5815e09eb2a01fdb57a351ef915b", size = 72984, upload-time = "2025-07-20T17:31:56.738Z" }, 486 + ] 487 + 488 + [[package]] 489 + name = "typing-extensions" 490 + version = "4.14.1" 491 + source = { registry = "https://pypi.org/simple" } 492 + sdist = { url = "https://files.pythonhosted.org/packages/98/5a/da40306b885cc8c09109dc2e1abd358d5684b1425678151cdaed4731c822/typing_extensions-4.14.1.tar.gz", hash = "sha256:38b39f4aeeab64884ce9f74c94263ef78f3c22467c8724005483154c26648d36", size = 107673, upload-time = "2025-07-04T13:28:34.16Z" } 493 + wheels = [ 494 + { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, 495 + ] 496 + 497 + [[package]] 498 + name = "typing-inspection" 499 + version = "0.4.1" 500 + source = { registry = "https://pypi.org/simple" } 501 + dependencies = [ 502 + { name = "typing-extensions" }, 503 + ] 504 + sdist = { url = "https://files.pythonhosted.org/packages/f8/b1/0c11f5058406b3af7609f121aaa6b609744687f1d158b3c3a5bf4cc94238/typing_inspection-0.4.1.tar.gz", hash = "sha256:6ae134cc0203c33377d43188d4064e9b357dba58cff3185f22924610e70a9d28", size = 75726, upload-time = "2025-05-21T18:55:23.885Z" } 505 + wheels = [ 506 + { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, 507 + ] 508 + 509 + [[package]] 510 + name = "uvicorn" 511 + version = "0.35.0" 512 + source = { registry = "https://pypi.org/simple" } 513 + dependencies = [ 514 + { name = "click" }, 515 + { name = "h11" }, 516 + ] 517 + sdist = { url = "https://files.pythonhosted.org/packages/5e/42/e0e305207bb88c6b8d3061399c6a961ffe5fbb7e2aa63c9234df7259e9cd/uvicorn-0.35.0.tar.gz", hash = "sha256:bc662f087f7cf2ce11a1d7fd70b90c9f98ef2e2831556dd078d131b96cc94a01", size = 78473, upload-time = "2025-06-28T16:15:46.058Z" } 518 + wheels = [ 519 + { url = "https://files.pythonhosted.org/packages/d2/e2/dc81b1bd1dcfe91735810265e9d26bc8ec5da45b4c0f6237e286819194c3/uvicorn-0.35.0-py3-none-any.whl", hash = "sha256:197535216b25ff9b785e29a0b79199f55222193d47f820816e7da751e9bc8d4a", size = 66406, upload-time = "2025-06-28T16:15:44.816Z" }, 520 + ] 521 + 522 + [[package]] 523 + name = "websockets" 524 + version = "13.1" 525 + source = { registry = "https://pypi.org/simple" } 526 + sdist = { url = "https://files.pythonhosted.org/packages/e2/73/9223dbc7be3dcaf2a7bbf756c351ec8da04b1fa573edaf545b95f6b0c7fd/websockets-13.1.tar.gz", hash = "sha256:a3b3366087c1bc0a2795111edcadddb8b3b59509d5db5d7ea3fdd69f954a8878", size = 158549, upload-time = "2024-09-21T17:34:21.54Z" } 527 + wheels = [ 528 + { url = "https://files.pythonhosted.org/packages/df/46/c426282f543b3c0296cf964aa5a7bb17e984f58dde23460c3d39b3148fcf/websockets-13.1-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:9d75baf00138f80b48f1eac72ad1535aac0b6461265a0bcad391fc5aba875cfc", size = 157821, upload-time = "2024-09-21T17:32:56.442Z" }, 529 + { url = "https://files.pythonhosted.org/packages/aa/85/22529867010baac258da7c45848f9415e6cf37fef00a43856627806ffd04/websockets-13.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:9b6f347deb3dcfbfde1c20baa21c2ac0751afaa73e64e5b693bb2b848efeaa49", size = 155480, upload-time = "2024-09-21T17:32:57.698Z" }, 530 + { url = "https://files.pythonhosted.org/packages/29/2c/bdb339bfbde0119a6e84af43ebf6275278698a2241c2719afc0d8b0bdbf2/websockets-13.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:de58647e3f9c42f13f90ac7e5f58900c80a39019848c5547bc691693098ae1bd", size = 155715, upload-time = "2024-09-21T17:32:59.429Z" }, 531 + { url = "https://files.pythonhosted.org/packages/9f/d0/8612029ea04c5c22bf7af2fd3d63876c4eaeef9b97e86c11972a43aa0e6c/websockets-13.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a1b54689e38d1279a51d11e3467dd2f3a50f5f2e879012ce8f2d6943f00e83f0", size = 165647, upload-time = "2024-09-21T17:33:00.495Z" }, 532 + { url = "https://files.pythonhosted.org/packages/56/04/1681ed516fa19ca9083f26d3f3a302257e0911ba75009533ed60fbb7b8d1/websockets-13.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cf1781ef73c073e6b0f90af841aaf98501f975d306bbf6221683dd594ccc52b6", size = 164592, upload-time = "2024-09-21T17:33:02.223Z" }, 533 + { url = "https://files.pythonhosted.org/packages/38/6f/a96417a49c0ed132bb6087e8e39a37db851c70974f5c724a4b2a70066996/websockets-13.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d23b88b9388ed85c6faf0e74d8dec4f4d3baf3ecf20a65a47b836d56260d4b9", size = 165012, upload-time = "2024-09-21T17:33:03.288Z" }, 534 + { url = "https://files.pythonhosted.org/packages/40/8b/fccf294919a1b37d190e86042e1a907b8f66cff2b61e9befdbce03783e25/websockets-13.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3c78383585f47ccb0fcf186dcb8a43f5438bd7d8f47d69e0b56f71bf431a0a68", size = 165311, upload-time = "2024-09-21T17:33:04.728Z" }, 535 + { url = "https://files.pythonhosted.org/packages/c1/61/f8615cf7ce5fe538476ab6b4defff52beb7262ff8a73d5ef386322d9761d/websockets-13.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:d6d300f8ec35c24025ceb9b9019ae9040c1ab2f01cddc2bcc0b518af31c75c14", size = 164692, upload-time = "2024-09-21T17:33:05.829Z" }, 536 + { url = "https://files.pythonhosted.org/packages/5c/f1/a29dd6046d3a722d26f182b783a7997d25298873a14028c4760347974ea3/websockets-13.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a9dcaf8b0cc72a392760bb8755922c03e17a5a54e08cca58e8b74f6902b433cf", size = 164686, upload-time = "2024-09-21T17:33:06.823Z" }, 537 + { url = "https://files.pythonhosted.org/packages/0f/99/ab1cdb282f7e595391226f03f9b498f52109d25a2ba03832e21614967dfa/websockets-13.1-cp312-cp312-win32.whl", hash = "sha256:2f85cf4f2a1ba8f602298a853cec8526c2ca42a9a4b947ec236eaedb8f2dc80c", size = 158712, upload-time = "2024-09-21T17:33:07.877Z" }, 538 + { url = "https://files.pythonhosted.org/packages/46/93/e19160db48b5581feac8468330aa11b7292880a94a37d7030478596cc14e/websockets-13.1-cp312-cp312-win_amd64.whl", hash = "sha256:38377f8b0cdeee97c552d20cf1865695fcd56aba155ad1b4ca8779a5b6ef4ac3", size = 159145, upload-time = "2024-09-21T17:33:09.202Z" }, 539 + { url = "https://files.pythonhosted.org/packages/51/20/2b99ca918e1cbd33c53db2cace5f0c0cd8296fc77558e1908799c712e1cd/websockets-13.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a9ab1e71d3d2e54a0aa646ab6d4eebfaa5f416fe78dfe4da2839525dc5d765c6", size = 157828, upload-time = "2024-09-21T17:33:10.987Z" }, 540 + { url = "https://files.pythonhosted.org/packages/b8/47/0932a71d3d9c0e9483174f60713c84cee58d62839a143f21a2bcdbd2d205/websockets-13.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b9d7439d7fab4dce00570bb906875734df13d9faa4b48e261c440a5fec6d9708", size = 155487, upload-time = "2024-09-21T17:33:12.153Z" }, 541 + { url = "https://files.pythonhosted.org/packages/a9/60/f1711eb59ac7a6c5e98e5637fef5302f45b6f76a2c9d64fd83bbb341377a/websockets-13.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:327b74e915cf13c5931334c61e1a41040e365d380f812513a255aa804b183418", size = 155721, upload-time = "2024-09-21T17:33:13.909Z" }, 542 + { url = "https://files.pythonhosted.org/packages/6a/e6/ba9a8db7f9d9b0e5f829cf626ff32677f39824968317223605a6b419d445/websockets-13.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:325b1ccdbf5e5725fdcb1b0e9ad4d2545056479d0eee392c291c1bf76206435a", size = 165609, upload-time = "2024-09-21T17:33:14.967Z" }, 543 + { url = "https://files.pythonhosted.org/packages/c1/22/4ec80f1b9c27a0aebd84ccd857252eda8418ab9681eb571b37ca4c5e1305/websockets-13.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:346bee67a65f189e0e33f520f253d5147ab76ae42493804319b5716e46dddf0f", size = 164556, upload-time = "2024-09-21T17:33:17.113Z" }, 544 + { url = "https://files.pythonhosted.org/packages/27/ac/35f423cb6bb15600438db80755609d27eda36d4c0b3c9d745ea12766c45e/websockets-13.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:91a0fa841646320ec0d3accdff5b757b06e2e5c86ba32af2e0815c96c7a603c5", size = 164993, upload-time = "2024-09-21T17:33:18.168Z" }, 545 + { url = "https://files.pythonhosted.org/packages/31/4e/98db4fd267f8be9e52e86b6ee4e9aa7c42b83452ea0ea0672f176224b977/websockets-13.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:18503d2c5f3943e93819238bf20df71982d193f73dcecd26c94514f417f6b135", size = 165360, upload-time = "2024-09-21T17:33:19.233Z" }, 546 + { url = "https://files.pythonhosted.org/packages/3f/15/3f0de7cda70ffc94b7e7024544072bc5b26e2c1eb36545291abb755d8cdb/websockets-13.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:a9cd1af7e18e5221d2878378fbc287a14cd527fdd5939ed56a18df8a31136bb2", size = 164745, upload-time = "2024-09-21T17:33:20.361Z" }, 547 + { url = "https://files.pythonhosted.org/packages/a1/6e/66b6b756aebbd680b934c8bdbb6dcb9ce45aad72cde5f8a7208dbb00dd36/websockets-13.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:70c5be9f416aa72aab7a2a76c90ae0a4fe2755c1816c153c1a2bcc3333ce4ce6", size = 164732, upload-time = "2024-09-21T17:33:23.103Z" }, 548 + { url = "https://files.pythonhosted.org/packages/35/c6/12e3aab52c11aeb289e3dbbc05929e7a9d90d7a9173958477d3ef4f8ce2d/websockets-13.1-cp313-cp313-win32.whl", hash = "sha256:624459daabeb310d3815b276c1adef475b3e6804abaf2d9d2c061c319f7f187d", size = 158709, upload-time = "2024-09-21T17:33:24.196Z" }, 549 + { url = "https://files.pythonhosted.org/packages/41/d8/63d6194aae711d7263df4498200c690a9c39fb437ede10f3e157a6343e0d/websockets-13.1-cp313-cp313-win_amd64.whl", hash = "sha256:c518e84bb59c2baae725accd355c8dc517b4a3ed8db88b4bc93c78dae2974bf2", size = 159144, upload-time = "2024-09-21T17:33:25.96Z" }, 550 + { url = "https://files.pythonhosted.org/packages/56/27/96a5cd2626d11c8280656c6c71d8ab50fe006490ef9971ccd154e0c42cd2/websockets-13.1-py3-none-any.whl", hash = "sha256:a9a396a6ad26130cdae92ae10c36af09d9bfe6cafe69670fd3b6da9b07b4044f", size = 152134, upload-time = "2024-09-21T17:34:19.904Z" }, 551 + ]