agenda/agenda/thespacedevs.py

185 lines
5.8 KiB
Python

"""Get details of upcoming space launches."""
import json
import os
import typing
from datetime import datetime
import requests
from .types import StrDict
from .utils import filename_timestamp, get_most_recent_file
Launch = dict[str, typing.Any]
Summary = dict[str, typing.Any]
ttl = 60 * 60 * 2 # two hours
LIMIT = 500
def next_launch_api_data(rocket_dir: str, limit: int = LIMIT) -> StrDict | None:
"""Get the next upcoming launches from the API."""
now = datetime.now()
filename = os.path.join(rocket_dir, now.strftime("%Y-%m-%d_%H:%M:%S.json"))
url = "https://ll.thespacedevs.com/2.2.0/launch/upcoming/"
params: dict[str, str | int] = {"limit": limit}
r = requests.get(url, params=params)
try:
data: StrDict = r.json()
except requests.exceptions.JSONDecodeError:
return None
open(filename, "w").write(r.text)
return data
def next_launch_api(rocket_dir: str, limit: int = LIMIT) -> list[Summary] | None:
"""Get the next upcoming launches from the API."""
data = next_launch_api_data(rocket_dir, limit)
if not data:
return None
return [summarize_launch(launch) for launch in data["results"]]
def format_time(time_str: str, net_precision: str) -> tuple[str, str | None]:
"""Format time based on precision."""
dt = datetime.strptime(time_str, "%Y-%m-%dT%H:%M:%SZ")
include_time = False
# Format the date based on precision
time_format: str | None
field2 = None
match net_precision:
case "Year":
time_format = "%Y"
case "Month":
time_format = "%b %Y"
case "Week":
time_format = "%d %b %Y"
field2 = "(Week of)"
case "Day" | "Hour" | "Minute":
time_format = "%d %b %Y"
include_time = net_precision in {"Hour", "Minute"}
case "Second":
time_format = "%d %b %Y"
include_time = True
case _ if net_precision and net_precision.startswith("Quarter "):
time_format = f"Q{net_precision[-1]} %Y"
case _:
time_format = None
if not time_format:
return (repr(time_str), repr(net_precision))
assert time_format
formatted = dt.strftime(time_format)
if include_time:
return (formatted, dt.strftime("%H:%M"))
else:
return (formatted, field2)
launch_providers = {
"Indian Space Research Organization": "ISRO",
"United Launch Alliance": "ULA",
"Payload Aerospace S.L.": "Payload Aerospace",
"Russian Federal Space Agency (ROSCOSMOS)": "ROSCOSMOS",
"China Aerospace Science and Technology Corporation": "CASC",
}
def get_nested(data: dict[str, typing.Any], keys: list[str]) -> typing.Any | None:
"""
Safely get a nested value from a dictionary.
Args:
data (Dict[str, Any]): The dictionary to search.
keys (List[str]): A list of keys for the nested lookup.
Returns:
Optional[Any]: The retrieved value, or None if any key is missing.
"""
for key in keys:
if data is None or key not in data:
return None
data = data[key]
return data
def summarize_launch(launch: Launch) -> Summary:
"""Summarize rocket launch."""
try:
launch_provider = launch["launch_service_provider"]["name"]
launch_provider_abbrev = launch_providers.get(launch_provider)
except (TypeError, IndexError):
launch_provider = None
launch_provider_abbrev = None
net_precision = typing.cast(str, get_nested(launch, ["net_precision", "name"]))
t0_date, t0_time = format_time(launch["net"], net_precision)
return {
"name": launch.get("name"),
"slug": launch["slug"],
"status": launch.get("status"),
"net": launch.get("net"),
"net_precision": net_precision,
"t0_date": t0_date,
"t0_time": t0_time,
"window_start": launch.get("window_start"),
"window_end": launch.get("window_end"),
"launch_provider": launch_provider,
"launch_provider_abbrev": launch_provider_abbrev,
"launch_provider_type": get_nested(launch, ["launch_service_provider", "type"]),
"rocket": launch["rocket"]["configuration"],
"mission": launch.get("mission"),
"mission_name": get_nested(launch, ["mission", "name"]),
"pad_name": launch["pad"]["name"],
"pad_wikipedia_url": launch["pad"]["wiki_url"],
"location": launch["pad"]["location"]["name"],
"country_code": launch["pad"]["country_code"],
"orbit": get_nested(launch, ["mission", "orbit"]),
"probability": launch["probability"],
"weather_concerns": launch["weather_concerns"],
}
def load_cached_launches(rocket_dir: str) -> StrDict | None:
"""Read the most recent cache of launches."""
filename = get_most_recent_file(rocket_dir, "json")
return typing.cast(StrDict, json.load(open(filename))) if filename else None
def read_cached_launches(rocket_dir: str) -> list[Summary]:
"""Read cached launches."""
data = load_cached_launches(rocket_dir)
return [summarize_launch(launch) for launch in data["results"]] if data else []
def get_launches(
rocket_dir: str, limit: int = LIMIT, refresh: bool = False
) -> list[Summary] | None:
"""Get rocket launches with caching."""
now = datetime.now()
existing = [
x for x in (filename_timestamp(f, "json") for f in os.listdir(rocket_dir)) if x
]
existing.sort(reverse=True)
if refresh or not existing or (now - existing[0][0]).seconds > ttl:
try:
return next_launch_api(rocket_dir, limit=limit)
except Exception:
pass # fallback to cached version
f = existing[0][1]
filename = os.path.join(rocket_dir, f)
data = json.load(open(filename))
return [summarize_launch(launch) for launch in data["results"]]