for assorted things

Compare changes

Choose any two refs to compare.

+37
README.md
··· 10 10 11 11 ## scripts 12 12 13 + - [`analyze-github-followers`](#analyze-github-followers) 13 14 - [`check-files-for-bad-links`](#check-files-for-bad-links) 14 15 - [`dm-me-when-a-flight-passes-over`](#dm-me-when-a-flight-passes-over) 15 16 - [`find-longest-bsky-thread`](#find-longest-bsky-thread) 17 + - [`find-stale-bsky-follows`](#find-stale-bsky-follows) 16 18 - [`kill-processes`](#kill-processes) 17 19 - [`predict-github-stars`](#predict-github-stars) 18 20 - [`update-lights`](#update-lights) 19 21 - [`update-readme`](#update-readme) 22 + 23 + --- 24 + 25 + ### `analyze-github-followers` 26 + 27 + analyze your github followers and following. 28 + 29 + usage: 30 + ./analyze-github-followers 31 + ./analyze-github-followers --summary-only # skip detailed analysis 32 + 33 + details: 34 + - uses github rest api to fetch followers/following 35 + - shows rich tables with follower stats 36 + - identifies mutual follows, notable followers, etc. 37 + - requires GITHUB_TOKEN in .env file 20 38 21 39 --- 22 40 ··· 169 187 Details: 170 188 - uses [`atproto`](https://github.com/MarshalX/atproto) to fetch the thread 171 189 - uses [`jinja2`](https://github.com/pallets/jinja) to render the thread 190 + 191 + --- 192 + 193 + ### `find-stale-bsky-follows` 194 + 195 + Find stale/inactive accounts among those you follow on Bluesky. 196 + 197 + Usage: 198 + 199 + ```bash 200 + ./find-stale-bsky-follows 201 + # or with custom inactivity threshold (days) 202 + ./find-stale-bsky-follows --days 180 203 + ``` 204 + 205 + Details: 206 + - uses [`atproto`](https://github.com/MarshalX/atproto) to fetch following list 207 + - uses [`rich`](https://github.com/Textualize/rich) for pretty output 208 + - identifies accounts with no recent posts 172 209 173 210 --- 174 211
+366
analyze-github-followers
··· 1 + #!/usr/bin/env -S uv run --script --quiet 2 + # /// script 3 + # requires-python = ">=3.12" 4 + # dependencies = ["httpx", "rich", "pydantic-settings"] 5 + # /// 6 + """ 7 + analyze your github followers and following. 8 + 9 + usage: 10 + ./analyze-github-followers 11 + ./analyze-github-followers --summary-only # skip detailed analysis 12 + 13 + details: 14 + - uses github rest api to fetch followers/following 15 + - shows rich tables with follower stats 16 + - identifies mutual follows, notable followers, etc. 17 + - requires GITHUB_TOKEN in .env file 18 + """ 19 + 20 + from __future__ import annotations 21 + 22 + import argparse 23 + import os 24 + from datetime import datetime, timezone 25 + from typing import NamedTuple 26 + 27 + import httpx 28 + from pydantic import Field 29 + from pydantic_settings import BaseSettings, SettingsConfigDict 30 + from rich.console import Console 31 + from rich.panel import Panel 32 + from rich.progress import Progress, SpinnerColumn, TextColumn 33 + from rich.table import Table 34 + 35 + console = Console() 36 + 37 + 38 + class Settings(BaseSettings): 39 + """load settings from environment""" 40 + 41 + model_config = SettingsConfigDict( 42 + env_file=os.environ.get("ENV_FILE", ".env"), extra="ignore" 43 + ) 44 + github_token: str = Field(description="github api token") 45 + 46 + 47 + class GitHubUser(NamedTuple): 48 + """github user information""" 49 + 50 + login: str 51 + name: str | None 52 + bio: str | None 53 + followers: int 54 + following: int 55 + public_repos: int 56 + created_at: datetime 57 + url: str 58 + company: str | None 59 + location: str | None 60 + blog: str | None 61 + 62 + @property 63 + def follower_ratio(self) -> float: 64 + """ratio of followers to following (higher = more influential)""" 65 + if self.following == 0: 66 + return float(self.followers) if self.followers > 0 else 0.0 67 + return self.followers / self.following 68 + 69 + 70 + def _headers(token: str) -> dict[str, str]: 71 + return { 72 + "Accept": "application/vnd.github.v3+json", 73 + "Authorization": f"token {token}", 74 + } 75 + 76 + 77 + def get_authenticated_user(token: str) -> str: 78 + """get the authenticated user's login""" 79 + with httpx.Client() as client: 80 + r = client.get("https://api.github.com/user", headers=_headers(token)) 81 + r.raise_for_status() 82 + return r.json()["login"] 83 + 84 + 85 + def get_user_details(username: str, token: str) -> GitHubUser: 86 + """fetch detailed user information""" 87 + with httpx.Client() as client: 88 + r = client.get( 89 + f"https://api.github.com/users/{username}", headers=_headers(token) 90 + ) 91 + r.raise_for_status() 92 + data = r.json() 93 + return GitHubUser( 94 + login=data["login"], 95 + name=data.get("name"), 96 + bio=data.get("bio"), 97 + followers=data["followers"], 98 + following=data["following"], 99 + public_repos=data["public_repos"], 100 + created_at=datetime.fromisoformat(data["created_at"].replace("Z", "+00:00")), 101 + url=data["html_url"], 102 + company=data.get("company"), 103 + location=data.get("location"), 104 + blog=data.get("blog"), 105 + ) 106 + 107 + 108 + def get_all_followers(username: str, token: str) -> list[str]: 109 + """fetch all followers (just logins for now)""" 110 + followers = [] 111 + page = 1 112 + with httpx.Client() as client: 113 + while True: 114 + r = client.get( 115 + f"https://api.github.com/users/{username}/followers?page={page}&per_page=100", 116 + headers=_headers(token), 117 + ) 118 + r.raise_for_status() 119 + data = r.json() 120 + if not data: 121 + break 122 + followers.extend([user["login"] for user in data]) 123 + page += 1 124 + return followers 125 + 126 + 127 + def get_all_following(username: str, token: str) -> list[str]: 128 + """fetch all users being followed""" 129 + following = [] 130 + page = 1 131 + with httpx.Client() as client: 132 + while True: 133 + r = client.get( 134 + f"https://api.github.com/users/{username}/following?page={page}&per_page=100", 135 + headers=_headers(token), 136 + ) 137 + r.raise_for_status() 138 + data = r.json() 139 + if not data: 140 + break 141 + following.extend([user["login"] for user in data]) 142 + page += 1 143 + return following 144 + 145 + 146 + def main(): 147 + """main function to analyze github followers""" 148 + parser = argparse.ArgumentParser(description="analyze your github followers") 149 + parser.add_argument( 150 + "--summary-only", 151 + action="store_true", 152 + help="show summary only, skip detailed follower analysis", 153 + ) 154 + args = parser.parse_args() 155 + 156 + try: 157 + settings = Settings() # type: ignore 158 + except Exception as e: 159 + console.print(f"[red]error loading settings: {e}[/red]") 160 + console.print("[dim]ensure .env file exists with GITHUB_TOKEN[/dim]") 161 + return 162 + 163 + token = settings.github_token.strip() 164 + 165 + try: 166 + # get authenticated user 167 + username = get_authenticated_user(token) 168 + console.print(f"[blue]analyzing followers for @{username}[/blue]\n") 169 + 170 + # fetch user details 171 + with Progress( 172 + SpinnerColumn(), 173 + TextColumn("[progress.description]{task.description}"), 174 + console=console, 175 + ) as progress: 176 + task = progress.add_task("fetching your profile...", total=None) 177 + user = get_user_details(username, token) 178 + progress.update(task, completed=True) 179 + 180 + # show profile info 181 + profile_text = f"[bold cyan]@{user.login}[/bold cyan]" 182 + if user.name: 183 + profile_text += f" ({user.name})" 184 + profile_text += f"\n[dim]joined {user.created_at:%Y-%m-%d}[/dim]" 185 + if user.bio: 186 + profile_text += f"\n{user.bio}" 187 + if user.location: 188 + profile_text += f"\n📍 {user.location}" 189 + if user.company: 190 + profile_text += f"\n🏢 {user.company}" 191 + 192 + console.print(Panel.fit(profile_text, border_style="blue")) 193 + console.print() 194 + 195 + # fetch followers and following 196 + with Progress( 197 + SpinnerColumn(), 198 + TextColumn("[progress.description]{task.description}"), 199 + console=console, 200 + ) as progress: 201 + task1 = progress.add_task("fetching followers...", total=None) 202 + followers = get_all_followers(username, token) 203 + progress.update(task1, completed=True) 204 + 205 + task2 = progress.add_task("fetching following...", total=None) 206 + following = get_all_following(username, token) 207 + progress.update(task2, completed=True) 208 + 209 + # analyze relationships 210 + followers_set = set(followers) 211 + following_set = set(following) 212 + 213 + mutual = followers_set & following_set 214 + followers_only = followers_set - following_set 215 + following_only = following_set - followers_set 216 + 217 + # summary table 218 + summary_table = Table(show_header=True, header_style="bold magenta") 219 + summary_table.add_column("metric", style="cyan") 220 + summary_table.add_column("count", justify="right", style="white") 221 + 222 + summary_table.add_row("total followers", str(len(followers))) 223 + summary_table.add_row("total following", str(len(following))) 224 + summary_table.add_row("mutual follows", f"[green]{len(mutual)}[/green]") 225 + summary_table.add_row( 226 + "followers not following back", f"[yellow]{len(followers_only)}[/yellow]" 227 + ) 228 + summary_table.add_row( 229 + "following but not following back", f"[red]{len(following_only)}[/red]" 230 + ) 231 + summary_table.add_row("public repos", str(user.public_repos)) 232 + 233 + console.print(summary_table) 234 + console.print() 235 + 236 + # fetch details for all followers 237 + if followers and not args.summary_only: 238 + console.print( 239 + f"\n[bold yellow]analyzing all {len(followers)} followers...[/bold yellow]" 240 + ) 241 + follower_details = [] 242 + 243 + with Progress( 244 + SpinnerColumn(), 245 + TextColumn("[progress.description]{task.description}"), 246 + console=console, 247 + ) as progress: 248 + task = progress.add_task( 249 + f"fetching follower details...", total=len(followers) 250 + ) 251 + 252 + for follower_login in followers: 253 + try: 254 + details = get_user_details(follower_login, token) 255 + follower_details.append(details) 256 + except Exception: 257 + pass # skip if we can't fetch details 258 + progress.advance(task) 259 + 260 + if follower_details: 261 + # most influential followers by follower ratio 262 + # filter out accounts with very few followers to avoid noise 263 + influential = [f for f in follower_details if f.followers >= 100] 264 + influential = sorted(influential, key=lambda u: u.follower_ratio, reverse=True)[:10] 265 + 266 + console.print() 267 + console.print("[bold magenta]most influential followers:[/bold magenta]") 268 + console.print("[dim]ranked by followers-to-following ratio[/dim]\n") 269 + followers_table = Table(show_header=True, header_style="bold magenta") 270 + followers_table.add_column("username", style="cyan") 271 + followers_table.add_column("name", style="white") 272 + followers_table.add_column("followers", justify="right", style="blue") 273 + followers_table.add_column("following", justify="right", style="yellow") 274 + followers_table.add_column("ratio", justify="right", style="green") 275 + followers_table.add_column("mutual", justify="center", style="magenta") 276 + 277 + for follower in influential: 278 + is_mutual = "✓" if follower.login in mutual else "" 279 + followers_table.add_row( 280 + f"@{follower.login}", 281 + follower.name or "[dim]no name[/dim]", 282 + f"{follower.followers:,}", 283 + f"{follower.following:,}", 284 + f"{follower.follower_ratio:.1f}x", 285 + is_mutual, 286 + ) 287 + 288 + console.print(followers_table) 289 + 290 + # location analysis 291 + locations = [ 292 + f.location for f in follower_details if f.location 293 + ] 294 + if locations: 295 + from collections import Counter 296 + 297 + location_counts = Counter(locations).most_common(5) 298 + console.print("\n[bold magenta]top follower locations:[/bold magenta]") 299 + location_table = Table(show_header=False) 300 + location_table.add_column("location", style="cyan") 301 + location_table.add_column("count", justify="right", style="white") 302 + for loc, count in location_counts: 303 + location_table.add_row(loc, str(count)) 304 + console.print(location_table) 305 + 306 + # company analysis 307 + companies = [ 308 + f.company.lstrip("@").strip() 309 + for f in follower_details 310 + if f.company 311 + ] 312 + if companies: 313 + from collections import Counter 314 + 315 + company_counts = Counter(companies).most_common(5) 316 + console.print("\n[bold magenta]top follower companies:[/bold magenta]") 317 + company_table = Table(show_header=False) 318 + company_table.add_column("company", style="cyan") 319 + company_table.add_column("count", justify="right", style="white") 320 + for comp, count in company_counts: 321 + company_table.add_row(comp, str(count)) 322 + console.print(company_table) 323 + 324 + # account age analysis 325 + now = datetime.now(timezone.utc) 326 + ages_years = [(now - f.created_at).days / 365.25 for f in follower_details] 327 + avg_age = sum(ages_years) / len(ages_years) 328 + oldest = min(follower_details, key=lambda f: f.created_at) 329 + newest = max(follower_details, key=lambda f: f.created_at) 330 + 331 + console.print("\n[bold magenta]account age stats:[/bold magenta]") 332 + age_table = Table(show_header=False) 333 + age_table.add_column("metric", style="cyan") 334 + age_table.add_column("value", style="white") 335 + age_table.add_row("average follower account age", f"{avg_age:.1f} years") 336 + age_table.add_row("oldest follower account", f"@{oldest.login} ({oldest.created_at:%Y-%m-%d})") 337 + age_table.add_row("newest follower account", f"@{newest.login} ({newest.created_at:%Y-%m-%d})") 338 + console.print(age_table) 339 + 340 + # repo stats 341 + repo_counts = [f.public_repos for f in follower_details] 342 + avg_repos = sum(repo_counts) / len(repo_counts) 343 + most_repos = max(follower_details, key=lambda f: f.public_repos) 344 + 345 + console.print("\n[bold magenta]repository stats:[/bold magenta]") 346 + repo_table = Table(show_header=False) 347 + repo_table.add_column("metric", style="cyan") 348 + repo_table.add_column("value", style="white") 349 + repo_table.add_row("average repos per follower", f"{avg_repos:.1f}") 350 + repo_table.add_row("follower with most repos", f"@{most_repos.login} ({most_repos.public_repos:,} repos)") 351 + repo_table.add_row("followers with 0 repos", str(sum(1 for f in follower_details if f.public_repos == 0))) 352 + console.print(repo_table) 353 + 354 + except httpx.HTTPStatusError as e: 355 + if e.response.status_code == 401: 356 + console.print("[red]error: invalid github token[/red]") 357 + elif e.response.status_code == 403: 358 + console.print("[red]error: rate limit exceeded[/red]") 359 + else: 360 + console.print(f"[red]github api error: {e.response.status_code}[/red]") 361 + except Exception as e: 362 + console.print(f"[red]error: {e}[/red]") 363 + 364 + 365 + if __name__ == "__main__": 366 + main()
+2 -1
find-longest-bsky-thread
··· 38 38 39 39 bsky_handle: str 40 40 bsky_password: str 41 + bsky_pds_url: str = "https://bsky.social" 41 42 42 43 43 44 def extract_post_uri(bluesky_url: str) -> str: ··· 171 172 ) 172 173 return 173 174 174 - client = Client() 175 + client = Client(base_url=settings.bsky_pds_url) 175 176 try: 176 177 client.login(settings.bsky_handle, settings.bsky_password) 177 178 except Exception as e:
+268
find-stale-bsky-follows
··· 1 + #!/usr/bin/env -S uv run --script --quiet 2 + # /// script 3 + # requires-python = ">=3.12" 4 + # dependencies = ["atproto", "pydantic-settings", "rich"] 5 + # /// 6 + """ 7 + Find stale/inactive accounts among those you follow on Bluesky. 8 + 9 + Usage: 10 + 11 + ```bash 12 + ./find-stale-bsky-follows 13 + # or with custom inactivity threshold (days) 14 + ./find-stale-bsky-follows --days 180 15 + ``` 16 + 17 + Details: 18 + - uses [`atproto`](https://github.com/MarshalX/atproto) to fetch following list 19 + - uses [`rich`](https://github.com/Textualize/rich) for pretty output 20 + - identifies accounts with no recent posts 21 + """ 22 + 23 + import argparse 24 + import os 25 + from datetime import datetime, timedelta, timezone 26 + from typing import NamedTuple 27 + 28 + from atproto import Client 29 + from pydantic_settings import BaseSettings, SettingsConfigDict 30 + from rich.console import Console 31 + from rich.progress import Progress, SpinnerColumn, TextColumn 32 + from rich.table import Table 33 + 34 + 35 + class Settings(BaseSettings): 36 + """App settings loaded from environment variables""" 37 + 38 + model_config = SettingsConfigDict( 39 + env_file=os.environ.get("ENV_FILE", ".env"), extra="ignore" 40 + ) 41 + 42 + bsky_handle: str 43 + bsky_password: str 44 + bsky_pds_url: str = "https://bsky.social" 45 + 46 + 47 + class AccountActivity(NamedTuple): 48 + """Activity information for a Bluesky account""" 49 + 50 + handle: str 51 + display_name: str | None 52 + did: str 53 + posts_count: int 54 + last_post_date: datetime | None 55 + days_inactive: int | None 56 + is_stale: bool 57 + 58 + 59 + def get_following_list(client: Client) -> list[dict]: 60 + """Fetch all accounts the authenticated user follows""" 61 + following = [] 62 + cursor = None 63 + 64 + while True: 65 + assert client.me, "client.me should be set" 66 + response = client.get_follows(client.me.did, cursor=cursor) 67 + following.extend(response.follows) 68 + 69 + if not response.cursor: 70 + break 71 + cursor = response.cursor 72 + 73 + return following 74 + 75 + 76 + def check_account_activity( 77 + client: Client, actor: dict, inactivity_threshold_days: int 78 + ) -> AccountActivity: 79 + """ 80 + Check the activity of a single account. 81 + 82 + Returns AccountActivity with stale status based on: 83 + - No posts at all 84 + - Last post older than threshold 85 + """ 86 + handle = actor.handle 87 + did = actor.did 88 + display_name = getattr(actor, "display_name", None) 89 + 90 + try: 91 + # Get the user's profile to check post count 92 + profile = client.get_profile(handle) 93 + posts_count = profile.posts_count or 0 94 + 95 + # If no posts, immediately mark as stale 96 + if posts_count == 0: 97 + return AccountActivity( 98 + handle=handle, 99 + display_name=display_name, 100 + did=did, 101 + posts_count=0, 102 + last_post_date=None, 103 + days_inactive=None, 104 + is_stale=True, 105 + ) 106 + 107 + # Get author feed to find last post 108 + feed_response = client.get_author_feed(actor=handle, limit=1) 109 + 110 + last_post_date = None 111 + if feed_response.feed: 112 + last_post = feed_response.feed[0].post 113 + if hasattr(last_post.record, "created_at"): 114 + created_at_str = last_post.record.created_at 115 + # Parse ISO 8601 timestamp 116 + last_post_date = datetime.fromisoformat( 117 + created_at_str.replace("Z", "+00:00") 118 + ) 119 + 120 + # Calculate days inactive 121 + if last_post_date: 122 + days_inactive = (datetime.now(timezone.utc) - last_post_date).days 123 + is_stale = days_inactive > inactivity_threshold_days 124 + else: 125 + # Has posts but couldn't determine date - consider stale 126 + days_inactive = None 127 + is_stale = True 128 + 129 + return AccountActivity( 130 + handle=handle, 131 + display_name=display_name, 132 + did=did, 133 + posts_count=posts_count, 134 + last_post_date=last_post_date, 135 + days_inactive=days_inactive, 136 + is_stale=is_stale, 137 + ) 138 + 139 + except Exception as e: 140 + # If we can't check activity, mark as potentially problematic 141 + # (could be deleted, suspended, or private) 142 + return AccountActivity( 143 + handle=handle, 144 + display_name=display_name, 145 + did=did, 146 + posts_count=0, 147 + last_post_date=None, 148 + days_inactive=None, 149 + is_stale=True, 150 + ) 151 + 152 + 153 + def format_account_link(handle: str) -> str: 154 + """Format a clickable Bluesky profile link""" 155 + return f"https://bsky.app/profile/{handle}" 156 + 157 + 158 + def main(inactivity_threshold_days: int): 159 + """Main function to find stale accounts""" 160 + console = Console() 161 + 162 + try: 163 + settings = Settings() # type: ignore 164 + except Exception as e: 165 + console.print( 166 + f"[red]Error loading settings (ensure .env file exists with BSKY_HANDLE and BSKY_PASSWORD): {e}[/red]" 167 + ) 168 + return 169 + 170 + client = Client(base_url=settings.bsky_pds_url) 171 + try: 172 + client.login(settings.bsky_handle, settings.bsky_password) 173 + except Exception as e: 174 + console.print(f"[red]Error logging into Bluesky: {e}[/red]") 175 + return 176 + 177 + console.print(f"[blue]Logged in as {client.me.handle}[/blue]") 178 + console.print( 179 + f"[blue]Checking for accounts inactive for more than {inactivity_threshold_days} days...[/blue]\n" 180 + ) 181 + 182 + # Fetch following list 183 + with Progress( 184 + SpinnerColumn(), 185 + TextColumn("[progress.description]{task.description}"), 186 + console=console, 187 + ) as progress: 188 + task = progress.add_task("Fetching following list...", total=None) 189 + following = get_following_list(client) 190 + progress.update(task, completed=True) 191 + 192 + console.print(f"[green]Found {len(following)} accounts you follow[/green]\n") 193 + 194 + # Check activity for each account 195 + stale_accounts = [] 196 + with Progress( 197 + SpinnerColumn(), 198 + TextColumn("[progress.description]{task.description}"), 199 + console=console, 200 + ) as progress: 201 + task = progress.add_task("Analyzing account activity...", total=len(following)) 202 + 203 + for actor in following: 204 + activity = check_account_activity( 205 + client, actor, inactivity_threshold_days 206 + ) 207 + if activity.is_stale: 208 + stale_accounts.append(activity) 209 + progress.advance(task) 210 + 211 + # Display results 212 + console.print(f"\n[yellow]Found {len(stale_accounts)} stale accounts:[/yellow]\n") 213 + 214 + if stale_accounts: 215 + table = Table(show_header=True, header_style="bold magenta") 216 + table.add_column("Handle", style="cyan") 217 + table.add_column("Display Name", style="white") 218 + table.add_column("Posts", justify="right", style="blue") 219 + table.add_column("Last Post", style="yellow") 220 + table.add_column("Days Inactive", justify="right", style="red") 221 + 222 + # Sort by days inactive (None values last) 223 + stale_accounts.sort( 224 + key=lambda x: (x.days_inactive is None, x.days_inactive or 0), 225 + reverse=True, 226 + ) 227 + 228 + for account in stale_accounts: 229 + last_post = ( 230 + account.last_post_date.strftime("%Y-%m-%d") 231 + if account.last_post_date 232 + else "Never" 233 + ) 234 + days = str(account.days_inactive) if account.days_inactive else "Unknown" 235 + 236 + table.add_row( 237 + f"@{account.handle}", 238 + account.display_name or "[dim]No name[/dim]", 239 + str(account.posts_count), 240 + last_post, 241 + days, 242 + ) 243 + 244 + console.print(table) 245 + 246 + # Print links for easy access 247 + console.print("\n[dim]Profile links:[/dim]") 248 + for account in stale_accounts[:10]: # Limit to first 10 249 + console.print(f" {format_account_link(account.handle)}") 250 + if len(stale_accounts) > 10: 251 + console.print(f" [dim]... and {len(stale_accounts) - 10} more[/dim]") 252 + else: 253 + console.print("[green]All accounts you follow are active![/green]") 254 + 255 + 256 + if __name__ == "__main__": 257 + parser = argparse.ArgumentParser( 258 + description="Find stale/inactive accounts you follow on Bluesky." 259 + ) 260 + parser.add_argument( 261 + "--days", 262 + type=int, 263 + default=180, 264 + help="Number of days of inactivity to consider an account stale (default: 180)", 265 + ) 266 + args = parser.parse_args() 267 + 268 + main(args.days)
-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 - ]
+18 -9
update-lights
··· 1 1 #!/usr/bin/env -S uv run --script --quiet 2 2 # /// script 3 3 # requires-python = ">=3.12" 4 - # dependencies = ["marvin>=3.1.0"] 4 + # dependencies = ["marvin@git+https://github.com/prefecthq/marvin.git"] 5 5 # /// 6 6 """ 7 7 Make some change to my phillips hue network of lights via agent + MCP server. ··· 35 35 36 36 hue_bridge_ip: str = Field(default=...) 37 37 hue_bridge_username: str = Field(default=...) 38 + anthropic_api_key: str | None = Field(default=None) 38 39 39 - ai_model: KnownModelName = Field(default="gpt-4.1-mini") 40 + ai_model: KnownModelName = Field(default="anthropic:claude-opus-4-5") 40 41 41 42 42 43 settings = Settings() ··· 55 56 56 57 57 58 if __name__ == "__main__": 59 + import os 60 + 61 + if settings.anthropic_api_key: 62 + os.environ["ANTHROPIC_API_KEY"] = settings.anthropic_api_key 63 + 58 64 parser = argparse.ArgumentParser(description="Send a command to the Marvin agent.") 59 65 parser.add_argument( 60 66 "--message", ··· 63 69 default="soft and dim - Jessica Pratt energy, all areas", 64 70 help="The message to send to the agent (defaults to 'soft and dim - Jessica Pratt energy, all areas').", 65 71 ) 72 + parser.add_argument( 73 + "--once", 74 + action="store_true", 75 + help="Run once and exit instead of entering interactive mode.", 76 + ) 66 77 args = parser.parse_args() 67 78 68 79 agent = marvin.Agent( ··· 79 90 ) 80 91 81 92 with marvin.Thread(): 82 - first = True 83 - while True: 84 - if first: 85 - console.print(f"\n[bold yellow]→[/bold yellow] {args.message}") 86 - agent.run(str(args.message)) 87 - first = False 88 - else: 93 + console.print(f"\n[bold yellow]→[/bold yellow] {args.message}") 94 + agent.run(str(args.message)) 95 + 96 + if not args.once: 97 + while True: 89 98 try: 90 99 user_input = Prompt.ask( 91 100 "\n[bold green]enter a message[/bold green]"