+37
README.md
+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
+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
+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
+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
-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
-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
-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
-17
sandbox/flight-notifier/justfile
-25
sandbox/flight-notifier/pyproject.toml
-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
-3
sandbox/flight-notifier/src/flight_notifier/__init__.py
sandbox/flight-notifier/src/flight_notifier/__pycache__/__init__.cpython-312.pyc
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
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
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
-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
-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
-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
-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
+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]"