Various improvements
This commit is contained in:
parent
256fdef129
commit
c15b57ec87
2 changed files with 110 additions and 48 deletions
|
|
@ -39,25 +39,30 @@ CACHE_VERSION = 4
|
||||||
SourceInfo = dict[str, str]
|
SourceInfo = dict[str, str]
|
||||||
HIDE_UPLOADER = "Edward Betts <edward@4angle.com>"
|
HIDE_UPLOADER = "Edward Betts <edward@4angle.com>"
|
||||||
DEBIAN_SRC_BASE = Path.home() / "src" / "debian"
|
DEBIAN_SRC_BASE = Path.home() / "src" / "debian"
|
||||||
|
UDD_HIGH_LOAD_MARKER = "Current system load"
|
||||||
|
|
||||||
|
|
||||||
class PackageUpdateError(Exception):
|
class PackageUpdateError(Exception):
|
||||||
"""Base exception for package update operations."""
|
"""Base exception for package update operations."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class PackageNotFoundError(PackageUpdateError):
|
class PackageNotFoundError(PackageUpdateError):
|
||||||
"""Package not found in source info."""
|
"""Package not found in source info."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class RepositoryStateError(PackageUpdateError):
|
class RepositoryStateError(PackageUpdateError):
|
||||||
"""Repository in invalid state (uncommitted changes, etc)."""
|
"""Repository in invalid state (uncommitted changes, etc)."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
class ExternalCommandError(PackageUpdateError):
|
class ExternalCommandError(PackageUpdateError):
|
||||||
"""External command failed (salsa, gbp, dch)."""
|
"""External command failed (salsa, gbp, dch)."""
|
||||||
|
|
||||||
def __init__(self, command: str, returncode: int, message: str):
|
def __init__(self, command: str, returncode: int, message: str):
|
||||||
self.command = command
|
self.command = command
|
||||||
self.returncode = returncode
|
self.returncode = returncode
|
||||||
|
|
@ -66,6 +71,7 @@ class ExternalCommandError(PackageUpdateError):
|
||||||
|
|
||||||
class MissingToolError(PackageUpdateError):
|
class MissingToolError(PackageUpdateError):
|
||||||
"""Required external tool not available."""
|
"""Required external tool not available."""
|
||||||
|
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -104,7 +110,10 @@ def run_command(
|
||||||
raise ExternalCommandError(
|
raise ExternalCommandError(
|
||||||
command=cmd_str,
|
command=cmd_str,
|
||||||
returncode=result.returncode,
|
returncode=result.returncode,
|
||||||
message=f"{description} failed: {cmd_str} (exit code {result.returncode})",
|
message=(
|
||||||
|
f"{description} failed: {cmd_str} "
|
||||||
|
f"(exit code {result.returncode})"
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
except FileNotFoundError as e:
|
except FileNotFoundError as e:
|
||||||
|
|
@ -128,7 +137,8 @@ def check_required_tools() -> None:
|
||||||
if missing_tools:
|
if missing_tools:
|
||||||
tools_str = ", ".join(missing_tools)
|
tools_str = ", ".join(missing_tools)
|
||||||
raise MissingToolError(
|
raise MissingToolError(
|
||||||
f"Required tools not found: {tools_str}. Please install them to continue."
|
f"Required tools not found: {tools_str}. "
|
||||||
|
"Please install them to continue."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -217,15 +227,16 @@ def load_cache(source_paths: list[str]) -> Optional[dict[str, SourceInfo]]:
|
||||||
if not isinstance(key, str):
|
if not isinstance(key, str):
|
||||||
return None
|
return None
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
normalized[key] = {"vcs_git": value, "uploaders": ""}
|
normalized[key] = {"team": value, "uploaders": ""}
|
||||||
continue
|
continue
|
||||||
if not isinstance(value, dict):
|
if not isinstance(value, dict):
|
||||||
return None
|
return None
|
||||||
vcs_git = value.get("vcs_git")
|
team = value.get("team")
|
||||||
uploaders = value.get("uploaders")
|
uploaders = value.get("uploaders")
|
||||||
if not isinstance(vcs_git, str) or not isinstance(uploaders, str):
|
vcs_git = value.get("vcs_git")
|
||||||
|
if not isinstance(team, str) or not isinstance(uploaders, str):
|
||||||
return None
|
return None
|
||||||
normalized[key] = {"vcs_git": vcs_git, "uploaders": uploaders}
|
normalized[key] = {"team": team, "uploaders": uploaders, "vcs_git": vcs_git}
|
||||||
return normalized
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -282,8 +293,9 @@ def load_source_info_map() -> dict[str, SourceInfo]:
|
||||||
)
|
)
|
||||||
if team or uploaders_text:
|
if team or uploaders_text:
|
||||||
vcs_by_source[source] = {
|
vcs_by_source[source] = {
|
||||||
"vcs_git": team or "",
|
"team": team or "",
|
||||||
"uploaders": uploaders_text,
|
"uploaders": uploaders_text,
|
||||||
|
"vcs_git": vcs_git,
|
||||||
}
|
}
|
||||||
save_cache(source_paths, vcs_by_source)
|
save_cache(source_paths, vcs_by_source)
|
||||||
return vcs_by_source
|
return vcs_by_source
|
||||||
|
|
@ -299,29 +311,41 @@ def fetch_todo_list(timeout: int = 30) -> TodoList:
|
||||||
List of TODO items
|
List of TODO items
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
PackageUpdateError: If network request fails or JSON is invalid
|
PackageUpdateError: If network request fails or JSON is
|
||||||
|
invalid
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
with urlopen(TODO_URL, timeout=timeout) as response:
|
with urlopen(TODO_URL, timeout=timeout) as response:
|
||||||
payload = response.read().decode("utf-8")
|
payload = response.read()
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
raise PackageUpdateError(
|
raise PackageUpdateError(
|
||||||
f"HTTP error {e.code} while fetching TODO list from {TODO_URL}: {e.reason}"
|
f"HTTP error {e.code} while fetching TODO list from "
|
||||||
|
f"{TODO_URL}: {e.reason}"
|
||||||
) from e
|
) from e
|
||||||
except urllib.error.URLError as e:
|
except urllib.error.URLError as e:
|
||||||
raise PackageUpdateError(
|
raise PackageUpdateError(
|
||||||
f"Network error while fetching TODO list from {TODO_URL}: {e.reason}"
|
f"Network error while fetching TODO list from " f"{TODO_URL}: {e.reason}"
|
||||||
) from e
|
) from e
|
||||||
except TimeoutError as e:
|
except TimeoutError as e:
|
||||||
raise PackageUpdateError(
|
raise PackageUpdateError(
|
||||||
f"Timeout after {timeout}s while fetching TODO list from {TODO_URL}"
|
f"Timeout after {timeout}s while fetching TODO list " f"from {TODO_URL}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return cast(TodoList, json.loads(payload))
|
return cast(TodoList, json.loads(payload))
|
||||||
except json.JSONDecodeError as e:
|
except json.JSONDecodeError as e:
|
||||||
|
response_text = payload.decode("utf-8", errors="replace").strip()
|
||||||
|
if UDD_HIGH_LOAD_MARKER in response_text:
|
||||||
raise PackageUpdateError(
|
raise PackageUpdateError(
|
||||||
f"Invalid JSON in TODO list response: {e}"
|
"UDD returned a temporary overload page instead of JSON. "
|
||||||
|
"Please retry later."
|
||||||
|
) from e
|
||||||
|
|
||||||
|
preview = response_text[:120]
|
||||||
|
if len(response_text) > 120:
|
||||||
|
preview += "..."
|
||||||
|
raise PackageUpdateError(
|
||||||
|
f"Invalid JSON in TODO list response: {preview}"
|
||||||
) from e
|
) from e
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -456,7 +480,10 @@ def list_todos(include_prerelease: bool) -> None:
|
||||||
save_todo_list(todo_list)
|
save_todo_list(todo_list)
|
||||||
except PackageUpdateError as e:
|
except PackageUpdateError as e:
|
||||||
print(f"Error: {e}", file=sys.stderr)
|
print(f"Error: {e}", file=sys.stderr)
|
||||||
print("Please try again later or check your network connection.", file=sys.stderr)
|
print(
|
||||||
|
"Please try again later or check your network connection.",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
else:
|
else:
|
||||||
with TODO_PATH.open("r", encoding="utf-8") as handle:
|
with TODO_PATH.open("r", encoding="utf-8") as handle:
|
||||||
|
|
@ -480,14 +507,14 @@ def list_todos(include_prerelease: bool) -> None:
|
||||||
for todo in filtered:
|
for todo in filtered:
|
||||||
new_version, current_version = parse_details(todo[":details"])
|
new_version, current_version = parse_details(todo[":details"])
|
||||||
source_info = source_info_map.get(todo[":source"], {})
|
source_info = source_info_map.get(todo[":source"], {})
|
||||||
vcs_git = source_info.get("vcs_git")
|
team = source_info.get("team")
|
||||||
uploaders = source_info.get("uploaders", "")
|
uploaders = source_info.get("uploaders", "")
|
||||||
source = todo[":source"]
|
source = todo[":source"]
|
||||||
note = notes_by_source.get(source, "")
|
note = notes_by_source.get(source, "")
|
||||||
display_new = new_version
|
display_new = new_version
|
||||||
if is_prerelease_version(todo[":details"]):
|
if is_prerelease_version(todo[":details"]):
|
||||||
display_new = f"[yellow]{new_version} (pre)[/yellow]"
|
display_new = f"[yellow]{new_version} (pre)[/yellow]"
|
||||||
display_team = vcs_git or "-"
|
display_team = team or "-"
|
||||||
if display_team == "homeassistant-team":
|
if display_team == "homeassistant-team":
|
||||||
display_team = "HA"
|
display_team = "HA"
|
||||||
elif display_team.endswith("-team"):
|
elif display_team.endswith("-team"):
|
||||||
|
|
@ -516,7 +543,7 @@ def list_todos(include_prerelease: bool) -> None:
|
||||||
console.print(f"Packages: {len(filtered)}")
|
console.print(f"Packages: {len(filtered)}")
|
||||||
|
|
||||||
|
|
||||||
def update_todos() -> None:
|
def refresh_todos() -> None:
|
||||||
"""Fetch latest TODO list from UDD and show changes."""
|
"""Fetch latest TODO list from UDD and show changes."""
|
||||||
old_list: TodoList = []
|
old_list: TodoList = []
|
||||||
if TODO_PATH.exists():
|
if TODO_PATH.exists():
|
||||||
|
|
@ -609,20 +636,21 @@ def validate_package_info(package: str) -> SourceInfo:
|
||||||
Source info dictionary containing vcs_git and uploaders
|
Source info dictionary containing vcs_git and uploaders
|
||||||
|
|
||||||
Raises:
|
Raises:
|
||||||
PackageNotFoundError: If package not found or has no vcs_git
|
PackageNotFoundError: If package not found or has no
|
||||||
|
vcs_git
|
||||||
"""
|
"""
|
||||||
source_info_map = load_source_info_map()
|
source_info_map = load_source_info_map()
|
||||||
source_info = source_info_map.get(package)
|
source_info = source_info_map.get(package)
|
||||||
|
|
||||||
if not source_info or not source_info.get("vcs_git"):
|
if not source_info or not source_info.get("team"):
|
||||||
raise PackageNotFoundError(
|
raise PackageNotFoundError(f"Could not find team info for package '{package}'")
|
||||||
f"Could not find team info for package '{package}'"
|
|
||||||
)
|
|
||||||
|
|
||||||
return source_info
|
return source_info
|
||||||
|
|
||||||
|
|
||||||
def resolve_package_directories(package: str, source_info: SourceInfo) -> tuple[Path, Path, Path]:
|
def resolve_package_directories(
|
||||||
|
package: str, source_info: SourceInfo
|
||||||
|
) -> tuple[Path, Path, Path]:
|
||||||
"""Resolve team_dir, pkg_dir, and repo_dir paths.
|
"""Resolve team_dir, pkg_dir, and repo_dir paths.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -632,7 +660,7 @@ def resolve_package_directories(package: str, source_info: SourceInfo) -> tuple[
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (team_dir, pkg_dir, repo_dir)
|
Tuple of (team_dir, pkg_dir, repo_dir)
|
||||||
"""
|
"""
|
||||||
team_slug = source_info["vcs_git"]
|
team_slug = source_info["team"]
|
||||||
display_team = team_slug_to_display_name(team_slug).lower()
|
display_team = team_slug_to_display_name(team_slug).lower()
|
||||||
|
|
||||||
team_dir = DEBIAN_SRC_BASE / display_team
|
team_dir = DEBIAN_SRC_BASE / display_team
|
||||||
|
|
@ -642,7 +670,9 @@ def resolve_package_directories(package: str, source_info: SourceInfo) -> tuple[
|
||||||
return team_dir, pkg_dir, repo_dir
|
return team_dir, pkg_dir, repo_dir
|
||||||
|
|
||||||
|
|
||||||
def ensure_package_checkout(package: str, source_info: SourceInfo, pkg_dir: Path) -> None:
|
def ensure_package_checkout(
|
||||||
|
package: str, source_info: SourceInfo, pkg_dir: Path
|
||||||
|
) -> None:
|
||||||
"""Checkout package from salsa.
|
"""Checkout package from salsa.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -657,13 +687,12 @@ def ensure_package_checkout(package: str, source_info: SourceInfo, pkg_dir: Path
|
||||||
try:
|
try:
|
||||||
pkg_dir.mkdir(parents=True, exist_ok=True)
|
pkg_dir.mkdir(parents=True, exist_ok=True)
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
raise RepositoryStateError(
|
raise RepositoryStateError(f"Failed to create directory {pkg_dir}: {e}") from e
|
||||||
f"Failed to create directory {pkg_dir}: {e}"
|
|
||||||
) from e
|
|
||||||
|
|
||||||
print(f"Checking out {package} into {pkg_dir}...")
|
print(f"Checking out {package} into {pkg_dir}...")
|
||||||
team_slug = source_info["vcs_git"]
|
assert source_info["vcs_git"].startswith("https://salsa.debian.org/")
|
||||||
salsa_path = f"{team_slug}/deps/{package}"
|
salsa_path = source_info["vcs_git"][len("https://salsa.debian.org/") : -4]
|
||||||
|
print(" ".join(["salsa", "checkout", salsa_path]))
|
||||||
run_command(
|
run_command(
|
||||||
["salsa", "checkout", salsa_path],
|
["salsa", "checkout", salsa_path],
|
||||||
pkg_dir,
|
pkg_dir,
|
||||||
|
|
@ -682,8 +711,7 @@ def validate_repository_state(repo_dir: Path) -> None:
|
||||||
"""
|
"""
|
||||||
if has_uncommitted_changes(repo_dir):
|
if has_uncommitted_changes(repo_dir):
|
||||||
raise RepositoryStateError(
|
raise RepositoryStateError(
|
||||||
f"{repo_dir} has uncommitted changes. "
|
f"{repo_dir} has uncommitted changes. " "Please commit or stash them first."
|
||||||
"Please commit or stash them first."
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -696,6 +724,12 @@ def run_gbp_pq_workflow(repo_dir: Path) -> None:
|
||||||
Raises:
|
Raises:
|
||||||
ExternalCommandError: If gbp pq commands fail
|
ExternalCommandError: If gbp pq commands fail
|
||||||
"""
|
"""
|
||||||
|
run_command(
|
||||||
|
["gbp", "pq", "drop"],
|
||||||
|
repo_dir,
|
||||||
|
"Import patch queue",
|
||||||
|
)
|
||||||
|
|
||||||
run_command(
|
run_command(
|
||||||
["gbp", "pq", "import"],
|
["gbp", "pq", "import"],
|
||||||
repo_dir,
|
repo_dir,
|
||||||
|
|
@ -731,7 +765,7 @@ def update_debian_control(repo_dir: Path) -> None:
|
||||||
|
|
||||||
- Removes obsolete 'Priority: optional' (now default)
|
- Removes obsolete 'Priority: optional' (now default)
|
||||||
- Removes obsolete 'Rules-Requires-Root: no' (now default)
|
- Removes obsolete 'Rules-Requires-Root: no' (now default)
|
||||||
- Updates Standards-Version to current (4.7.3)
|
- Updates Standards-Version to current (4.7.4)
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
repo_dir: Repository directory
|
repo_dir: Repository directory
|
||||||
|
|
@ -744,7 +778,7 @@ def update_debian_control(repo_dir: Path) -> None:
|
||||||
return
|
return
|
||||||
|
|
||||||
lines = control_path.read_text().splitlines()
|
lines = control_path.read_text().splitlines()
|
||||||
current_standards_version = "4.7.3"
|
current_standards_version = "4.7.4"
|
||||||
new_lines = []
|
new_lines = []
|
||||||
|
|
||||||
for line in lines:
|
for line in lines:
|
||||||
|
|
@ -781,8 +815,10 @@ def update_debian_copyright_year(repo_dir: Path) -> None:
|
||||||
"""Update Edward Betts' copyright years in debian/copyright.
|
"""Update Edward Betts' copyright years in debian/copyright.
|
||||||
|
|
||||||
Transforms:
|
Transforms:
|
||||||
- "Copyright: 2025 Edward Betts <...>" -> "Copyright: 2025-2026 Edward Betts <...>"
|
- "Copyright: 2025 Edward Betts <...>" ->
|
||||||
- "Copyright: 2022-2024 Edward Betts <...>" -> "Copyright: 2022-2026 Edward Betts <...>"
|
"Copyright: 2025-2026 Edward Betts <...>"
|
||||||
|
- "Copyright: 2022-2024 Edward Betts <...>" ->
|
||||||
|
"Copyright: 2022-2026 Edward Betts <...>"
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
repo_dir: Repository directory
|
repo_dir: Repository directory
|
||||||
|
|
@ -831,7 +867,9 @@ def update_debian_copyright_year(repo_dir: Path) -> None:
|
||||||
for line in lines:
|
for line in lines:
|
||||||
stripped = line.rstrip("\n")
|
stripped = line.rstrip("\n")
|
||||||
|
|
||||||
if field_start_re.match(stripped) and not copyright_field_start_re.match(stripped):
|
if field_start_re.match(stripped) and not copyright_field_start_re.match(
|
||||||
|
stripped
|
||||||
|
):
|
||||||
in_copyright_field = False
|
in_copyright_field = False
|
||||||
|
|
||||||
if copyright_field_start_re.match(stripped):
|
if copyright_field_start_re.match(stripped):
|
||||||
|
|
@ -1024,10 +1062,12 @@ def add_salsa_ci(repo_dir: Path) -> None:
|
||||||
if not salsa_repo:
|
if not salsa_repo:
|
||||||
return
|
return
|
||||||
|
|
||||||
content = """---
|
content = (
|
||||||
include:
|
"---\n"
|
||||||
- https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/recipes/debian.yml
|
"include:\n"
|
||||||
"""
|
" - https://salsa.debian.org/salsa-ci-team/pipeline"
|
||||||
|
"/raw/master/recipes/debian.yml\n"
|
||||||
|
)
|
||||||
|
|
||||||
salsa_ci_path.write_text(content)
|
salsa_ci_path.write_text(content)
|
||||||
|
|
||||||
|
|
@ -1142,8 +1182,11 @@ def update_package(package: str) -> None:
|
||||||
# Run package updates
|
# Run package updates
|
||||||
run_package_updates(repo_dir)
|
run_package_updates(repo_dir)
|
||||||
|
|
||||||
|
run_command(["debuild", "-S"], repo_dir, "debuild -S")
|
||||||
|
os.remove(repo_dir / "debian/files")
|
||||||
|
|
||||||
print(f"Successfully updated {package}")
|
print(f"Successfully updated {package}")
|
||||||
print(repo_dir)
|
print(pkg_dir)
|
||||||
|
|
||||||
|
|
||||||
@click.group(invoke_without_command=True)
|
@click.group(invoke_without_command=True)
|
||||||
|
|
@ -1164,14 +1207,14 @@ def list_command(show_prerelease: bool) -> None:
|
||||||
list_todos(include_prerelease=show_prerelease)
|
list_todos(include_prerelease=show_prerelease)
|
||||||
|
|
||||||
|
|
||||||
@cli.command("update", help="Fetch the latest todo list and show changes.")
|
@cli.command("refresh", help="Fetch the latest todo list and show changes.")
|
||||||
def update_command() -> None:
|
def refresh_command() -> None:
|
||||||
update_todos()
|
refresh_todos()
|
||||||
|
|
||||||
|
|
||||||
@cli.command("update-pkg", help="Update a package to a new upstream version.")
|
@cli.command("update", help="Update a package to a new upstream version.")
|
||||||
@click.argument("package")
|
@click.argument("package")
|
||||||
def update_pkg_command(package: str) -> None:
|
def update_command(package: str) -> None:
|
||||||
try:
|
try:
|
||||||
update_package(package)
|
update_package(package)
|
||||||
except PackageUpdateError as e:
|
except PackageUpdateError as e:
|
||||||
|
|
|
||||||
|
|
@ -155,6 +155,25 @@ class TestFetchTodoListErrors:
|
||||||
with pytest.raises(debian_todo.PackageUpdateError) as exc_info:
|
with pytest.raises(debian_todo.PackageUpdateError) as exc_info:
|
||||||
debian_todo.fetch_todo_list()
|
debian_todo.fetch_todo_list()
|
||||||
assert "Invalid JSON" in str(exc_info.value)
|
assert "Invalid JSON" in str(exc_info.value)
|
||||||
|
assert "not valid json {" in str(exc_info.value)
|
||||||
|
|
||||||
|
def test_udd_high_load_html_response(self):
|
||||||
|
"""Test known UDD overload HTML response handling."""
|
||||||
|
with patch("debian_todo.urlopen") as mock_urlopen:
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.read.return_value = (
|
||||||
|
b"<p><b>Current system load (26.1) is too high. "
|
||||||
|
b"Please retry later!</b></p>\n"
|
||||||
|
)
|
||||||
|
mock_response.__enter__ = lambda self: self
|
||||||
|
mock_response.__exit__ = lambda self, *args: None
|
||||||
|
mock_urlopen.return_value = mock_response
|
||||||
|
|
||||||
|
with pytest.raises(debian_todo.PackageUpdateError) as exc_info:
|
||||||
|
debian_todo.fetch_todo_list()
|
||||||
|
|
||||||
|
assert "temporary overload page" in str(exc_info.value)
|
||||||
|
assert "Please retry later" in str(exc_info.value)
|
||||||
|
|
||||||
def test_successful_fetch(self):
|
def test_successful_fetch(self):
|
||||||
"""Test successful fetch."""
|
"""Test successful fetch."""
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue