metar-cli 0.3.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
metar.py ADDED
@@ -0,0 +1,768 @@
1
+ #!/usr/bin/env python3
2
+ """metar-cli — METAR + TAF terminal dashboard"""
3
+
4
+ import argparse
5
+ import os
6
+ import re
7
+ import sys
8
+ import termios
9
+ import tty
10
+ import requests
11
+ from datetime import datetime, timezone, timedelta
12
+ from pathlib import Path
13
+ from rich.console import Console
14
+ from rich.text import Text
15
+ from rich.panel import Panel
16
+ from rich.table import Table
17
+ from rich.columns import Columns
18
+
19
+ console = Console()
20
+ CONFIG_FILE = Path.home() / ".config" / "metar" / "config"
21
+ FALLBACK_ICAO = "MMML"
22
+
23
+
24
+ def get_default_icao():
25
+ if icao := os.environ.get("METAR_ICAO"):
26
+ return icao.upper()
27
+ if CONFIG_FILE.exists():
28
+ val = CONFIG_FILE.read_text().strip()
29
+ if val:
30
+ return val.upper()
31
+ return FALLBACK_ICAO
32
+
33
+
34
+ def set_default_icao(icao):
35
+ CONFIG_FILE.parent.mkdir(parents=True, exist_ok=True)
36
+ CONFIG_FILE.write_text(icao.upper() + "\n")
37
+ console.print(f"Default station set to [bold]{icao.upper()}[/bold] ({CONFIG_FILE})")
38
+ METAR_URL = "https://aviationweather.gov/api/data/metar"
39
+ TAF_URL = "https://aviationweather.gov/api/data/taf"
40
+ ASOS_URL = "https://mesonet.agron.iastate.edu/cgi-bin/request/asos.py"
41
+
42
+ FR_STYLES = {
43
+ "VFR": ("green", "bold white on green"),
44
+ "MVFR": ("blue", "bold white on blue"),
45
+ "IFR": ("red", "bold white on red"),
46
+ "LIFR": ("magenta", "bold white on magenta"),
47
+ }
48
+
49
+ ROSE_GRID = [
50
+ ["↖", "↑", "↗"],
51
+ ["←", "·", "→"],
52
+ ["↙", "↓", "↘"],
53
+ ]
54
+ ROSE_POS = {
55
+ "N": (2, 1), "NE": (2, 0), "E": (1, 0), "SE": (0, 0),
56
+ "S": (0, 1), "SW": (0, 2), "W": (1, 2), "NW": (2, 2),
57
+ }
58
+
59
+ CHANGE_STYLES = {
60
+ "FM": "bold cyan",
61
+ "BECMG": "bold yellow",
62
+ "TEMPO": "bold magenta",
63
+ "PROB30": "dim magenta",
64
+ "PROB40": "dim magenta",
65
+ }
66
+
67
+
68
+ def deg_to_cardinal(deg):
69
+ dirs = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"]
70
+ return dirs[round(float(deg) / 45) % 8]
71
+
72
+
73
+ def render_rose(wdir, wspd, color):
74
+ active = ROSE_POS[deg_to_cardinal(wdir)] if wdir and wspd > 0 else None
75
+ t = Text()
76
+ for r in range(3):
77
+ for c in range(3):
78
+ ch = ROSE_GRID[r][c]
79
+ style = f"bold {color}" if active and (r, c) == active else "dim"
80
+ t.append(ch, style=style)
81
+ t.append(" ")
82
+ if r < 2:
83
+ t.append("\n")
84
+ return t
85
+
86
+
87
+ SPARKS = "▁▂▃▄▅▆▇█"
88
+
89
+
90
+ def sparkline(values):
91
+ clean = [v for v in values if v is not None]
92
+ if len(clean) < 2:
93
+ return "─" * len(values)
94
+ lo, hi = min(clean), max(clean)
95
+ if lo == hi:
96
+ return SPARKS[3] * len(values)
97
+ return "".join(
98
+ SPARKS[round((v - lo) / (hi - lo) * 7)] if v is not None else " "
99
+ for v in values
100
+ )
101
+
102
+
103
+ def fetch_metar(icao):
104
+ resp = requests.get(METAR_URL, params={"ids": icao, "format": "json"}, timeout=10)
105
+ resp.raise_for_status()
106
+ if not resp.content:
107
+ raise ValueError(f"No METAR data for {icao}")
108
+ data = resp.json()
109
+ if not data:
110
+ raise ValueError(f"No METAR data for {icao}")
111
+ return data[0]
112
+
113
+
114
+ def fetch_taf(icao):
115
+ resp = requests.get(TAF_URL, params={"ids": icao, "format": "json"}, timeout=10)
116
+ resp.raise_for_status()
117
+ if not resp.content:
118
+ return None
119
+ data = resp.json()
120
+ return data[0] if data else None
121
+
122
+
123
+ def fetch_history(icao, hours=6):
124
+ now = datetime.now(tz=timezone.utc)
125
+ start = now - timedelta(hours=hours)
126
+ resp = requests.get(ASOS_URL, params={
127
+ "station": icao, "data": "tmpf,drct,sknt,alti",
128
+ "year1": start.year, "month1": start.month, "day1": start.day, "hour1": start.hour,
129
+ "year2": now.year, "month2": now.month, "day2": now.day, "hour2": now.hour,
130
+ "tz": "UTC", "format": "onlycomma", "latlon": "no", "missing": "null",
131
+ }, timeout=10)
132
+ records = []
133
+ for line in resp.text.strip().splitlines()[1:]:
134
+ parts = line.split(",")
135
+ if len(parts) < 6:
136
+ continue
137
+ try:
138
+ tmpf = float(parts[2]) if parts[2] != "null" else None
139
+ wspd = float(parts[4]) if parts[4] != "null" else None
140
+ alti = float(parts[5]) if parts[5] != "null" else None
141
+ records.append({
142
+ "temp": (tmpf - 32) * 5 / 9 if tmpf is not None else None,
143
+ "wspd": wspd,
144
+ "inhg": alti,
145
+ })
146
+ except (ValueError, IndexError):
147
+ continue
148
+ return records
149
+
150
+
151
+ def age_str(obs_time):
152
+ try:
153
+ dt = datetime.fromtimestamp(int(obs_time), tz=timezone.utc)
154
+ mins = int((datetime.now(tz=timezone.utc) - dt).total_seconds() / 60)
155
+ return f"{mins}m ago"
156
+ except Exception:
157
+ return ""
158
+
159
+
160
+ def fmt_time(ts):
161
+ try:
162
+ dt = datetime.fromtimestamp(int(ts), tz=timezone.utc)
163
+ return dt.strftime("%d/%Hz")
164
+ except Exception:
165
+ return "??Z"
166
+
167
+
168
+ def get_ceiling(clouds):
169
+ for layer in (clouds or []):
170
+ if layer.get("cover") in ("BKN", "OVC"):
171
+ return f"{layer['base']:,} ft"
172
+ return "None"
173
+
174
+
175
+ _WX_INTENSITY = {"-": "light", "+": "heavy"}
176
+ _WX_DESCRIPTOR = {
177
+ "TS": "thunderstorm", "FZ": "freezing", "BL": "blowing",
178
+ "DR": "drifting", "BC": "patchy", "MI": "shallow",
179
+ "PR": "partial", "SH": None, # handled as suffix "showers"
180
+ }
181
+ _WX_PHENOM = {
182
+ "DZ": "drizzle", "RA": "rain", "SN": "snow", "SG": "snow grains",
183
+ "IC": "ice", "PL": "pellets", "GR": "hail", "GS": "small hail",
184
+ "UP": "precip", "BR": "mist", "FG": "fog", "FU": "smoke",
185
+ "VA": "ash", "DU": "dust", "SA": "sand", "HZ": "haze",
186
+ "PY": "spray", "PO": "dust whirls", "SQ": "squalls",
187
+ "FC": "funnel cloud", "SS": "sandstorm", "DS": "duststorm",
188
+ }
189
+
190
+ def _decode_wx_token(tok):
191
+ i = 0
192
+ nearby = False
193
+ intensity = ""
194
+ descs = []
195
+ phenom = []
196
+
197
+ if tok[i:i+2] == "VC":
198
+ nearby = True
199
+ i += 2
200
+
201
+ if i < len(tok) and tok[i] in "-+":
202
+ intensity = _WX_INTENSITY[tok[i]]
203
+ i += 1
204
+
205
+ while i + 1 < len(tok) + 1:
206
+ code = tok[i:i+2]
207
+ if not code:
208
+ break
209
+ if code in _WX_DESCRIPTOR:
210
+ descs.append(code)
211
+ i += 2
212
+ elif code in _WX_PHENOM:
213
+ phenom.append(_WX_PHENOM[code])
214
+ i += 2
215
+ else:
216
+ i += 1
217
+
218
+ words = []
219
+ if intensity:
220
+ words.append(intensity)
221
+ for d in ("TS", "FZ", "BL", "DR", "BC", "MI", "PR"):
222
+ if d in descs:
223
+ words.append(_WX_DESCRIPTOR[d])
224
+ words.extend(phenom)
225
+ if "SH" in descs:
226
+ words.append("showers")
227
+ if nearby:
228
+ words.append("nearby")
229
+ return " ".join(words)
230
+
231
+
232
+ def decode_wx(wx_str):
233
+ if not wx_str:
234
+ return ""
235
+ return " · ".join(_decode_wx_token(t) for t in wx_str.split() if t)
236
+
237
+
238
+ def hpa_to_inhg(hpa):
239
+ return f"{float(hpa) * 0.02953:.2f}"
240
+
241
+
242
+ MAX_SPARK_POINTS = 12
243
+
244
+ def _sample(values, n=MAX_SPARK_POINTS):
245
+ if len(values) <= n:
246
+ return values
247
+ step = len(values) / n
248
+ return [values[round(i * step)] for i in range(n)]
249
+
250
+
251
+ def render_history(history):
252
+ if len(history) < 2:
253
+ return None
254
+ all_temps = [r.get("temp") for r in history]
255
+ all_winds = [r.get("wspd") for r in history]
256
+ all_inhgs = [r.get("inhg") for r in history]
257
+
258
+ def cell(label, all_vals, spark_style, fmt, unit):
259
+ clean = [v for v in all_vals if v is not None]
260
+ t = Text()
261
+ t.append(f"{label} ", style="dim")
262
+ t.append(sparkline(_sample(all_vals)), style=f"bold {spark_style}")
263
+ if clean:
264
+ t.append(f" {fmt.format(min(clean))}→{fmt.format(max(clean))} {unit}", style=f"dim {spark_style}")
265
+ return t
266
+
267
+ tbl = Table(box=None, show_header=False, padding=(0, 3), expand=False)
268
+ tbl.add_column()
269
+ tbl.add_column()
270
+ tbl.add_column()
271
+ tbl.add_row(
272
+ cell("temp", all_temps, "yellow", "{:.0f}", "°C"),
273
+ cell("wind", all_winds, "cyan", "{:.0f}", "kt"),
274
+ cell("QNH", all_inhgs, "white", "{:.2f}", "inHg"),
275
+ )
276
+ return tbl
277
+
278
+
279
+ def parse_iso(iso_str):
280
+ try:
281
+ dt = datetime.fromisoformat(iso_str.replace("Z", "+00:00"))
282
+ mins = int((datetime.now(tz=timezone.utc) - dt).total_seconds() / 60)
283
+ return f"{mins}m ago"
284
+ except Exception:
285
+ return ""
286
+
287
+
288
+ def render_taf(taf):
289
+ if not taf:
290
+ return
291
+
292
+ valid_from = fmt_time(taf.get("validTimeFrom", 0))
293
+ valid_to = fmt_time(taf.get("validTimeTo", 0))
294
+ issued = parse_iso(taf.get("issueTime", ""))
295
+
296
+ t = Text()
297
+ t.append(f"valid {valid_from} → {valid_to}", style="dim white")
298
+ t.append(f" issued {issued}\n\n", style="dim cyan")
299
+
300
+ for period in taf.get("fcsts", []):
301
+ change = period.get("fcstChange") or "BASE"
302
+ time_from = fmt_time(period.get("timeFrom", 0))
303
+ time_to = fmt_time(period.get("timeTo", 0))
304
+
305
+ label_style = CHANGE_STYLES.get(change, "bold white")
306
+
307
+ t.append(f"{change:<6} ", style=label_style)
308
+ t.append(f"{time_from}→{time_to} ", style="dim")
309
+
310
+ wdir = period.get("wdir")
311
+ wspd = period.get("wspd") or 0
312
+ wgst = period.get("wgst")
313
+ if wspd == 0 or wspd is None:
314
+ t.append("Calm ", style="cyan")
315
+ else:
316
+ t.append(f"{int(wspd)}", style="bold cyan")
317
+ if wgst:
318
+ t.append(f"G{int(wgst)}", style="bold red")
319
+ t.append("kt", style="cyan")
320
+ if wdir == "VRB":
321
+ t.append(" VRB", style="dim cyan")
322
+ elif wdir is not None:
323
+ card = deg_to_cardinal(wdir)
324
+ t.append(f" {int(wdir)}°{card}", style="dim cyan")
325
+ t.append(" ")
326
+
327
+ vis = period.get("visib")
328
+ if vis is not None:
329
+ t.append(f"{vis}SM ", style="white")
330
+
331
+ clouds = period.get("clouds") or []
332
+ for c in clouds:
333
+ cover = c.get("cover", "")
334
+ base = c.get("base")
335
+ if cover == "SKC" or cover == "CLR":
336
+ t.append("SKC ", style="dim")
337
+ elif base is not None:
338
+ t.append(f"{cover} {base:,}ft ", style="dim white")
339
+
340
+ wx = period.get("wxString")
341
+ if wx:
342
+ t.append(wx, style="bold yellow")
343
+
344
+ t.append("\n")
345
+
346
+ raw = taf.get("rawTAF", "")
347
+ if raw:
348
+ t.append("\n")
349
+ t.append(raw, style="dim white")
350
+
351
+ console.print(Panel(t, title="[dim]TAF[/dim]", border_style="dim"))
352
+
353
+
354
+ def density_alt(temp_c, altim_hpa, elev_m):
355
+ elev_ft = elev_m * 3.28084
356
+ altim_inhg = altim_hpa * 0.02953
357
+ pa = (29.92 - altim_inhg) * 1000 + elev_ft
358
+ isa_temp = 15.0 - (1.98 * pa / 1000)
359
+ return round(pa + 120 * (temp_c - isa_temp))
360
+
361
+
362
+ def parse_remarks(raw):
363
+ if "RMK" not in raw:
364
+ return []
365
+ rmk = raw.split("RMK", 1)[1].strip()
366
+ items = []
367
+ tokens = rmk.split()
368
+ i = 0
369
+ while i < len(tokens):
370
+ tok = tokens[i]
371
+
372
+ m = re.match(r'^SLP(\d{3})$', tok)
373
+ if m:
374
+ v = int(m.group(1))
375
+ hpa = (1000 + v / 10) if v < 500 else (900 + v / 10)
376
+ items.append(("SLP", f"{hpa:.1f} hPa"))
377
+
378
+ elif re.match(r'^T[01]\d{3}[01]\d{3}$', tok):
379
+ ts, ds = tok[1:5], tok[5:]
380
+ tv = int(ts[1:]) / 10 * (-1 if ts[0] == "1" else 1)
381
+ dv = int(ds[1:]) / 10 * (-1 if ds[0] == "1" else 1)
382
+ items.append(("T/Td", f"{tv:.1f}° / {dv:.1f}°"))
383
+
384
+ elif tok == "AO2":
385
+ items.append(("stn", "ASOS / auto"))
386
+ elif tok == "AO1":
387
+ items.append(("stn", "auto (no precip ID)"))
388
+
389
+ elif tok == "PK" and i + 2 < len(tokens) and tokens[i + 1] == "WND":
390
+ m2 = re.match(r'^(\d{3})(\d{2,3})/(\d{4})$', tokens[i + 2])
391
+ if m2:
392
+ items.append(("pk wind", f"{m2.group(2)}kt {m2.group(1)}° :{m2.group(3)[2:]}"))
393
+ i += 2
394
+
395
+ elif tok == "WSHFT" and i + 1 < len(tokens):
396
+ t_str = tokens[i + 1]
397
+ items.append(("wshft", f":{t_str[2:]}"))
398
+ i += 1
399
+
400
+ elif tok == "PRESRR":
401
+ items.append(("pres Δ", "rising rapidly"))
402
+ elif tok == "PRESFR":
403
+ items.append(("pres Δ", "falling rapidly"))
404
+
405
+ elif re.match(r'^P\d{4}$', tok):
406
+ v = int(tok[1:])
407
+ items.append(("precip", "trace" if v == 0 else f"{v / 100:.2f} in"))
408
+
409
+ elif re.match(r'^6\d{4}$', tok):
410
+ v = int(tok[1:])
411
+ items.append(("6h precip", "trace" if v == 0 else f"{v / 100:.2f} in"))
412
+
413
+ elif tok == "TSNO":
414
+ items.append(("TS sensor", "N/A"))
415
+ elif tok == "RVRNO":
416
+ items.append(("RVR", "N/A"))
417
+ elif tok == "FZRANO":
418
+ items.append(("FZRA sensor", "N/A"))
419
+ elif tok == "PWINO":
420
+ items.append(("precip ID", "N/A"))
421
+
422
+ elif tok == "$":
423
+ items.append(("maint", "check needed"))
424
+
425
+ i += 1
426
+ return items
427
+
428
+
429
+ def render_analysis_panel(rmk_items):
430
+ t = Text()
431
+ for label, val in rmk_items:
432
+ t.append(f"{label:<10}", style="dim")
433
+ t.append(val + "\n", style="white")
434
+ if not rmk_items:
435
+ t.append("no remarks", style="dim")
436
+ return Panel(t, title="[dim]remarks[/dim]", border_style="dim")
437
+
438
+
439
+ def show_station(icao, show_taf=False, raw_only=False):
440
+ m = fetch_metar(icao)
441
+ history = fetch_history(icao)
442
+
443
+ fr = m.get("fltCat") or m.get("flightCategory", "VFR")
444
+ temp = m.get("temp")
445
+ dewp = m.get("dewp")
446
+ wdir = m.get("wdir")
447
+ wspd = m.get("wspd", 0)
448
+ wgst = m.get("wgst")
449
+ visib = m.get("visib", "—")
450
+ altim = m.get("altim")
451
+ elev = m.get("elev")
452
+ clouds = m.get("clouds", [])
453
+ wx = m.get("wxString") or ""
454
+ raw = m.get("rawOb", "")
455
+ name = m.get("name", "")
456
+ obs = m.get("obsTime", 0)
457
+
458
+ if raw_only:
459
+ console.print(raw)
460
+ return
461
+
462
+ fr_color, fr_style = FR_STYLES.get(fr, ("white", "bold white"))
463
+
464
+ # ── Header ──────────────────────────────────────────────────────────
465
+ hdr = Text()
466
+ hdr.append(f"METAR {icao}", style="bold white")
467
+ if name:
468
+ hdr.append(f" · {name}", style="dim white")
469
+ hdr.append(f" {age_str(obs)}", style="dim cyan")
470
+ console.print(hdr)
471
+ console.rule(style="dim white")
472
+
473
+ # ── Stat cards ───────────────────────────────────────────────────────
474
+ stats = Table(box=None, show_header=False, padding=(0, 2), expand=False)
475
+ for _ in range(6):
476
+ stats.add_column(justify="center")
477
+
478
+ fr_cell = Text(f" {fr} ", style=fr_style)
479
+
480
+ temp_cell = Text()
481
+ if temp is not None:
482
+ temp_cell.append(f"{int(temp)}°C", style="bold yellow")
483
+ if dewp is not None:
484
+ temp_cell.append(f" / {int(dewp)}°", style="dim yellow")
485
+
486
+ wind_val = Text()
487
+ wind_sub = Text("")
488
+ if not wspd or wspd == 0:
489
+ wind_val.append("Calm", style="bold cyan")
490
+ else:
491
+ wind_val.append(f"{int(wspd)}", style="bold cyan")
492
+ if wgst:
493
+ wind_val.append(f"G{int(wgst)}", style="bold red")
494
+ wind_val.append("kt", style="cyan")
495
+ wind_val.append(f" {int(wdir)}°", style="dim cyan")
496
+ wind_sub = Text(deg_to_cardinal(wdir), style="dim")
497
+
498
+ vis_cell = Text(f"{visib} mi", style="bold white")
499
+ ceil_cell = Text(get_ceiling(clouds), style="bold white")
500
+ qnh_cell = Text(f"{hpa_to_inhg(altim)} inHg" if altim else "—", style="bold white")
501
+
502
+ stats.add_row(fr_cell, temp_cell, wind_val, vis_cell, ceil_cell, qnh_cell)
503
+ stats.add_row(
504
+ Text(""),
505
+ Text(decode_wx(wx) or "clear", style="dim"),
506
+ wind_sub if wspd else Text(""),
507
+ Text("Visibility", style="dim"),
508
+ Text("Ceiling", style="dim"),
509
+ Text("QNH", style="dim"),
510
+ )
511
+
512
+ console.print(stats)
513
+ console.rule(style="dim white")
514
+
515
+ # ── Wind rose + cloud layers side by side ────────────────────────────
516
+ rose_text = render_rose(wdir, wspd, fr_color)
517
+ if temp is not None and altim and elev is not None:
518
+ da = density_alt(temp, altim, elev)
519
+ da_style = "bold green" if da < 3000 else ("bold yellow" if da < 5000 else "bold red")
520
+ elev_ft = round(elev * 3.28084)
521
+ rose_text.append("\n──────\n", style="dim")
522
+ rose_text.append("elev ", style="dim")
523
+ rose_text.append(f"{elev_ft:,}ft\n", style="white")
524
+ rose_text.append("DA ", style="dim")
525
+ rose_text.append(f"{da:,}ft", style=da_style)
526
+ rose_panel = Panel(rose_text, title="[dim]wind[/dim]", width=18, border_style="dim")
527
+
528
+ clouds_text = Text()
529
+ altitudes = [20000, 12000, 6000, 3000, 1500, 500, 0]
530
+ alt_labels = {20000: "20k", 12000: "12k", 6000: " 6k",
531
+ 3000: " 3k", 1500: "1.5", 500: "500", 0: "sfc"}
532
+ cloud_rows: dict = {}
533
+ for c in (clouds or []):
534
+ base = c.get("base")
535
+ if base is None:
536
+ continue
537
+ nearest = min(altitudes, key=lambda a: abs(a - base))
538
+ cloud_rows.setdefault(nearest, []).append(c["cover"])
539
+ for alt in altitudes:
540
+ clouds_text.append(f"{alt_labels[alt]} │ ", style="dim")
541
+ for cover in cloud_rows.get(alt, []):
542
+ clouds_text.append(f"☁ {cover}", style="bold white")
543
+ clouds_text.append("\n")
544
+ clouds_text.append(" └" + "─" * 12, style="dim")
545
+
546
+ clouds_panel = Panel(clouds_text, title="[dim]clouds[/dim]", width=22, border_style="dim")
547
+
548
+ rmk_items = parse_remarks(raw)
549
+ analysis_panel = render_analysis_panel(rmk_items)
550
+ console.print(Columns([rose_panel, clouds_panel, analysis_panel]))
551
+ console.rule(style="dim white")
552
+
553
+ # ── Sparkline history ────────────────────────────────────────────────
554
+ hist_text = render_history(history)
555
+ if hist_text:
556
+ console.print(Panel(hist_text, title="[dim]history (6h)[/dim]", border_style="dim"))
557
+ console.rule(style="dim white")
558
+
559
+ # ── Raw METAR ────────────────────────────────────────────────────────
560
+ console.print(Panel(Text(raw, style="dim white"), title="[dim]raw[/dim]", border_style="dim"))
561
+
562
+ # ── TAF ──────────────────────────────────────────────────────────────
563
+ if show_taf:
564
+ console.rule(style="dim white")
565
+ taf = fetch_taf(icao)
566
+ render_taf(taf)
567
+
568
+
569
+ def getch():
570
+ fd = sys.stdin.fileno()
571
+ old = termios.tcgetattr(fd)
572
+ try:
573
+ tty.setraw(fd)
574
+ return sys.stdin.read(1)
575
+ finally:
576
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
577
+
578
+
579
+ def _icao_dialog(title, subtitle, label, hint, border_color="#00aaff", error=None):
580
+ from prompt_toolkit import Application
581
+ from prompt_toolkit.buffer import Buffer
582
+ from prompt_toolkit.formatted_text import HTML
583
+ from prompt_toolkit.key_binding import KeyBindings
584
+ from prompt_toolkit.layout import Layout
585
+ from prompt_toolkit.layout.containers import HSplit, VSplit, Window, WindowAlign
586
+ from prompt_toolkit.layout.controls import BufferControl, FormattedTextControl
587
+ from prompt_toolkit.widgets import Frame
588
+ from prompt_toolkit.styles import Style
589
+
590
+ result = [None]
591
+ buf = Buffer()
592
+
593
+ kb = KeyBindings()
594
+
595
+ @kb.add("enter")
596
+ def _(event):
597
+ val = buf.text.strip().upper()
598
+ if val:
599
+ result[0] = val
600
+ event.app.exit()
601
+
602
+ @kb.add("c-c")
603
+ @kb.add("c-d")
604
+ def _(event):
605
+ event.app.exit()
606
+
607
+ def row(content, style=""):
608
+ return Window(
609
+ FormattedTextControl(content),
610
+ height=1,
611
+ align=WindowAlign.CENTER,
612
+ style=style,
613
+ )
614
+
615
+ body = [
616
+ Window(height=1),
617
+ row(HTML(f"<b>{title}</b>")),
618
+ row(subtitle, style="fg:#888888"),
619
+ Window(height=1),
620
+ ]
621
+
622
+ if error:
623
+ body.append(row(HTML(f"<ansired>✗ {error}</ansired>")))
624
+ body.append(Window(height=1))
625
+
626
+ body += [
627
+ row(label, style="fg:#aaaaaa"),
628
+ Window(height=1),
629
+ Frame(
630
+ Window(content=BufferControl(buffer=buf), height=1),
631
+ style="class:input-frame",
632
+ ),
633
+ Window(height=1),
634
+ row(hint, style="fg:#555555"),
635
+ Window(height=1),
636
+ ]
637
+
638
+ dialog = Frame(HSplit(body), style="class:outer-frame", width=36)
639
+ root = HSplit([Window(), VSplit([Window(), dialog, Window()]), Window()])
640
+
641
+ style = Style.from_dict({
642
+ "outer-frame frame.border": f"fg:{border_color}",
643
+ "outer-frame frame.label": f"bold fg:{border_color}",
644
+ "input-frame frame.border": "fg:#334455",
645
+ })
646
+
647
+ app = Application(
648
+ layout=Layout(root, focused_element=buf),
649
+ key_bindings=kb,
650
+ style=style,
651
+ full_screen=True,
652
+ mouse_support=False,
653
+ )
654
+ app.run()
655
+ return result[0]
656
+
657
+
658
+ def search_prompt(error=None):
659
+ return _icao_dialog(
660
+ title="✈ metar-cli",
661
+ subtitle="METAR + TAF · terminal dashboard",
662
+ label="input station",
663
+ hint="enter ↵ to search ctrl+c to quit",
664
+ error=error,
665
+ )
666
+
667
+
668
+ def config_prompt():
669
+ current = get_default_icao()
670
+ return _icao_dialog(
671
+ title="⚙ set default station",
672
+ subtitle=f"current default: {current}",
673
+ label="new default ICAO",
674
+ hint="enter ↵ to save ctrl+c to cancel",
675
+ border_color="#ffaa00",
676
+ )
677
+
678
+
679
+ def interactive_mode():
680
+ icao = None
681
+ show_taf = False
682
+ raw_mode = False
683
+ error = None
684
+
685
+ while True:
686
+ # ── Search screen ────────────────────────────────────────────────
687
+ if icao is None:
688
+ icao = search_prompt(error=error)
689
+ error = None
690
+ if icao is None:
691
+ console.clear()
692
+ return
693
+
694
+ # ── Display screen ───────────────────────────────────────────────
695
+ console.clear()
696
+ try:
697
+ show_station(icao, show_taf=show_taf, raw_only=raw_mode)
698
+ except (ValueError, requests.RequestException) as e:
699
+ error = str(e)
700
+ icao = None
701
+ continue
702
+
703
+ # ── Key bar ──────────────────────────────────────────────────────
704
+ console.print()
705
+ console.rule(style="dim")
706
+
707
+ bar = Text(justify="center")
708
+ bar.append("q", style="bold cyan"); bar.append(" quit", style="dim")
709
+ bar.append(" t", style="bold cyan" if not show_taf else "bold green")
710
+ bar.append(" taf" + (" ✓" if show_taf else ""), style="dim" if not show_taf else "green")
711
+ bar.append(" r", style="bold cyan" if not raw_mode else "bold green")
712
+ bar.append(" raw" + (" ✓" if raw_mode else ""), style="dim" if not raw_mode else "green")
713
+ bar.append(" s", style="bold cyan"); bar.append(" search", style="dim")
714
+ bar.append(" u", style="bold cyan"); bar.append(" refresh", style="dim")
715
+ bar.append(" c", style="bold cyan"); bar.append(" config", style="dim")
716
+ console.print(bar)
717
+
718
+ key = getch()
719
+ if key in ("q", "Q", "\x03"): # q or Ctrl+C
720
+ console.clear()
721
+ return
722
+ elif key in ("t", "T"):
723
+ show_taf = not show_taf
724
+ elif key in ("r", "R"):
725
+ raw_mode = not raw_mode
726
+ elif key in ("s", "S"):
727
+ icao = None
728
+ show_taf = False
729
+ raw_mode = False
730
+ elif key in ("c", "C"):
731
+ new_default = config_prompt()
732
+ if new_default:
733
+ set_default_icao(new_default)
734
+
735
+
736
+ def main():
737
+ parser = argparse.ArgumentParser(
738
+ prog="metar",
739
+ description="Aviation weather dashboard — METAR + TAF",
740
+ )
741
+ parser.add_argument(
742
+ "icao", nargs="*", default=[],
743
+ metavar="ICAO", help="One or more ICAO station codes",
744
+ )
745
+ parser.add_argument("--taf", action="store_true", help="Include TAF forecast block")
746
+ parser.add_argument("--raw", action="store_true", help="Print raw METAR string only")
747
+ parser.add_argument("-i", "--interactive", action="store_true", help="Interactive mode")
748
+ parser.add_argument("--set-default", metavar="ICAO", help="Save a default station to ~/.config/metar/config")
749
+ args = parser.parse_args()
750
+
751
+ if args.set_default:
752
+ set_default_icao(args.set_default)
753
+ return
754
+
755
+ if args.interactive:
756
+ interactive_mode()
757
+ return
758
+
759
+ stations = [s.upper() for s in args.icao] or [get_default_icao()]
760
+ for i, icao in enumerate(stations):
761
+ if i > 0:
762
+ console.print()
763
+ show_station(icao, show_taf=args.taf, raw_only=args.raw)
764
+
765
+
766
+ if __name__ == "__main__":
767
+ main()
768
+ # HOWDY! thanks for reading the code ;) - Alex gc / soup - weekend project finished 13 jun 2026
@@ -0,0 +1,18 @@
1
+ Metadata-Version: 2.4
2
+ Name: metar-cli
3
+ Version: 0.3.0
4
+ Summary: Terminal METAR + TAF dashboard for pilots and aviation nerds
5
+ Author: Alex gc
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/alexgc96/metar-cli
8
+ Classifier: Operating System :: MacOS
9
+ Classifier: Operating System :: POSIX :: Linux
10
+ Classifier: Environment :: Console
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Requires-Python: >=3.10
14
+ License-File: LICENSE
15
+ Requires-Dist: rich
16
+ Requires-Dist: requests
17
+ Requires-Dist: prompt_toolkit
18
+ Dynamic: license-file
@@ -0,0 +1,7 @@
1
+ metar.py,sha256=omhi0jJn1FPL6R53ZOMLnlzXNytpryKh0kzkRwnhrAk,25362
2
+ metar_cli-0.3.0.dist-info/licenses/LICENSE,sha256=MoMtSQu7lwZ0P_RzCXGg_P7AXbE-DguBG1O8uh_1knA,1064
3
+ metar_cli-0.3.0.dist-info/METADATA,sha256=ersoCujSEC5wKPGgBRUvXz5-j_7Epr6IWz-VajaRd8Q,576
4
+ metar_cli-0.3.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
5
+ metar_cli-0.3.0.dist-info/entry_points.txt,sha256=SDuhBrXetJk21fClPmJuOHGkMT7F3nMQvs3PvNvpDZw,37
6
+ metar_cli-0.3.0.dist-info/top_level.txt,sha256=OdcHEZjwJmgAVrqww48n4wgK2Ht6XcWFsf4ssKnNpOs,6
7
+ metar_cli-0.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ metar = metar:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alex gc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ metar