"""Ubuntu release info, upgrade target discovery, and PPA availability checks.
Reads /etc/update-manager/release-upgrades and /usr/share/distro-info/ubuntu.csv
to determine which Ubuntu releases exist and which the user may upgrade to.
Network functions check Launchpad PPA hosting for codename support.
"""
from __future__ import annotations
import configparser
import csv
import platform
import re
from datetime import datetime
from pathlib import Path
import requests
from .models import AvailabilityStatus
_RELEASE_UPGRADES_PATH = Path("/etc/update-manager/release-upgrades")
_DISTRO_INFO_CSV = Path("/usr/share/distro-info/ubuntu.csv")
_PPA_URL_TEMPLATE = "https://ppa.launchpadcontent.net/{owner}/{ppa}/ubuntu/dists/{codename}/InRelease"
_DISTS_URL_TEMPLATE = "https://ppa.launchpadcontent.net/{owner}/{ppa}/ubuntu/dists/"
[docs]
def get_current_codename_and_display() -> tuple[str, str]:
"""Return (codename, PRETTY_NAME) from /etc/os-release."""
try:
info = platform.freedesktop_os_release()
codename = info.get("UBUNTU_CODENAME") or info.get("VERSION_CODENAME", "")
display = info.get("PRETTY_NAME", "") or "Unknown Ubuntu release"
return codename, display
except OSError:
return "", "Unknown Ubuntu release"
[docs]
def get_upgrade_prompt() -> str:
"""Return 'lts', 'normal', or 'never' from /etc/update-manager/release-upgrades."""
config = configparser.ConfigParser()
try:
config.read(_RELEASE_UPGRADES_PATH)
return config.get("DEFAULT", "Prompt", fallback="lts").lower()
except configparser.Error:
return "lts"
def _parse_ubuntu_csv() -> list[dict]:
"""Parse /usr/share/distro-info/ubuntu.csv into release dicts sorted by date."""
try:
results = []
with _DISTRO_INFO_CSV.open() as f:
reader = csv.DictReader(f)
for row in reader:
try:
series = row.get("series", "").strip()
version = row.get("version", "").strip()
release_str = row.get("release", "").strip()
if not series or not version or not release_str:
continue
release_date = datetime.strptime(release_str, "%Y-%m-%d").date()
results.append(
{
"series": series,
"version": version,
"date": release_date,
"is_lts": "LTS" in version,
}
)
except (KeyError, ValueError):
continue
results.sort(key=lambda r: r["date"])
return results
except (OSError, csv.Error):
return []
[docs]
def get_upgrade_targets(current_codename: str, prompt: str) -> list[tuple[str, str]]:
"""Return [(codename, display_label), ...] for releases newer than current_codename.
prompt='lts' → LTS only; 'normal' → all; 'never' → [].
Includes future/unreleased codenames — PPAs can pre-publish.
"""
if prompt == "never":
return []
releases = _parse_ubuntu_csv()
if not releases:
return []
current_idx = next((i for i, r in enumerate(releases) if r["series"] == current_codename), None)
if current_idx is None:
return []
newer = releases[current_idx + 1 :]
if prompt == "lts":
newer = [r for r in newer if r["is_lts"]]
return [(r["series"], f"{r['series']} ({r['version']})") for r in newer]
[docs]
def get_all_known_codenames() -> list[str]:
"""Return all known Ubuntu codenames in release-date order (oldest first)."""
return [r["series"] for r in _parse_ubuntu_csv()]
[docs]
def get_ppa_suites(owner: str, ppa: str, *, timeout: int = 10) -> tuple[frozenset[str] | None, str | None]:
"""Return all Ubuntu suite names published by a Launchpad PPA.
Fetches the dists/ directory listing in a single GET request.
Returns (frozenset_of_codenames, None) on success,
(frozenset(), None) on 404 (PPA exists but no packages),
(None, error_str) on network failure.
"""
url = _DISTS_URL_TEMPLATE.format(owner=owner, ppa=ppa)
try:
resp = requests.get(url, timeout=timeout)
if resp.status_code == 404:
return frozenset(), None
if resp.status_code != 200:
return None, f"HTTP {resp.status_code} from {url}"
return frozenset(re.findall(r'href="([^/"]+)/"', resp.text)), None
except requests.exceptions.Timeout:
return None, f"Connection timed out checking {url}"
except requests.exceptions.ConnectionError as exc:
return None, str(exc)
except requests.exceptions.RequestException as exc:
return None, str(exc)
[docs]
def check_ppa_for_codename(
ppa_owner: str, ppa_name: str, codename: str, *, timeout: int = 10
) -> tuple[AvailabilityStatus, str | None]:
"""HTTP HEAD to Launchpad PPA InRelease for the given codename.
Returns (AVAILABLE, None) on 200, (UNAVAILABLE, None) on 404,
(UNKNOWN, error_message) on anything else.
Does not touch the global _network_failed state in checker.py.
"""
url = _PPA_URL_TEMPLATE.format(owner=ppa_owner, ppa=ppa_name, codename=codename)
try:
response = requests.head(url, timeout=timeout, allow_redirects=True)
if response.status_code == 200:
return AvailabilityStatus.AVAILABLE, None
if response.status_code == 404:
return AvailabilityStatus.UNAVAILABLE, None
return AvailabilityStatus.UNKNOWN, f"HTTP {response.status_code} from {url}"
except requests.exceptions.Timeout:
return AvailabilityStatus.UNKNOWN, f"Connection timed out checking {url}"
except requests.exceptions.ConnectionError as exc:
return AvailabilityStatus.UNKNOWN, str(exc)
except requests.exceptions.RequestException as exc:
return AvailabilityStatus.UNKNOWN, str(exc)