Add custom exception hierarchy (PackageUpdateError, PackageNotFoundError, RepositoryStateError, ExternalCommandError, MissingToolError) for specific error handling throughout package update operations. Extract helper functions from 90-line update_package function: - validate_package_info: Package validation - resolve_package_directories: Path resolution - ensure_package_checkout: Salsa checkout with error handling - validate_repository_state: Check for uncommitted changes - run_gbp_pq_workflow: Run gbp pq import/switch with error checks - import_upstream_version: Import upstream using gbp - run_command: Centralized subprocess execution with consistent errors - check_required_tools: Validate required tools upfront Incorporate update scripts as Python functions (replaces external shell scripts): - update_debian_control: Update Standards-Version, remove obsolete fields - update_debian_copyright_year: Update copyright years to current - update_debian_watch: Upgrade watch files from version 4 to 5 - add_salsa_ci: Add debian/salsa-ci.yml if missing - run_package_updates: Orchestrator for all update operations Enhance network error handling in fetch_todo_list: - Handle HTTP errors, network errors, timeouts, invalid JSON - Add timeout parameter (default 30s) - Graceful error messages in list_todos and update_todos Add comprehensive test coverage: - 67 tests for update-pkg functionality - Tests for all helper functions and update operations - Network error handling tests - 125 total tests, all passing Benefits: - No external script dependencies - Consistent error handling throughout - Better error messages with actionable guidance - All update logic in one maintainable codebase Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
914 lines
36 KiB
Python
914 lines
36 KiB
Python
"""Tests for update-pkg command functions."""
|
|
|
|
import datetime
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import urllib.error
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
import debian_todo
|
|
|
|
|
|
class TestRunCommand:
|
|
"""Tests for run_command helper function."""
|
|
|
|
def test_successful_command(self, tmp_path):
|
|
"""Test running a successful command."""
|
|
with patch("debian_todo.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="")
|
|
result = debian_todo.run_command(
|
|
["echo", "test"], tmp_path, "Test command"
|
|
)
|
|
assert result.returncode == 0
|
|
|
|
def test_command_with_capture_output(self, tmp_path):
|
|
"""Test running a command with output capture."""
|
|
with patch("debian_todo.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(
|
|
returncode=0, stdout="output", stderr=""
|
|
)
|
|
result = debian_todo.run_command(
|
|
["echo", "test"], tmp_path, "Test command", capture_output=True
|
|
)
|
|
assert result.stdout == "output"
|
|
mock_run.assert_called_once_with(
|
|
["echo", "test"], cwd=tmp_path, capture_output=True, text=True
|
|
)
|
|
|
|
def test_command_failure_with_check(self, tmp_path):
|
|
"""Test command failure raises ExternalCommandError when check=True."""
|
|
with patch("debian_todo.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
|
|
with pytest.raises(debian_todo.ExternalCommandError) as exc_info:
|
|
debian_todo.run_command(
|
|
["false"], tmp_path, "Test command", check=True
|
|
)
|
|
assert exc_info.value.returncode == 1
|
|
assert "false" in exc_info.value.command
|
|
assert "Test command failed" in str(exc_info.value)
|
|
|
|
def test_command_failure_without_check(self, tmp_path):
|
|
"""Test command failure returns result when check=False."""
|
|
with patch("debian_todo.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=1, stdout="", stderr="")
|
|
result = debian_todo.run_command(
|
|
["false"], tmp_path, "Test command", check=False
|
|
)
|
|
assert result.returncode == 1
|
|
|
|
def test_missing_command(self, tmp_path):
|
|
"""Test missing command raises MissingToolError."""
|
|
with patch("debian_todo.subprocess.run") as mock_run:
|
|
mock_run.side_effect = FileNotFoundError("command not found")
|
|
with pytest.raises(debian_todo.MissingToolError) as exc_info:
|
|
debian_todo.run_command(
|
|
["nonexistent"], tmp_path, "Test command"
|
|
)
|
|
assert "nonexistent" in str(exc_info.value)
|
|
|
|
|
|
class TestCheckRequiredTools:
|
|
"""Tests for check_required_tools function."""
|
|
|
|
def test_all_tools_available(self):
|
|
"""Test when all required tools are available."""
|
|
with patch("debian_todo.shutil.which") as mock_which:
|
|
mock_which.return_value = "/usr/bin/tool"
|
|
debian_todo.check_required_tools()
|
|
assert mock_which.call_count == 3
|
|
|
|
def test_missing_salsa(self):
|
|
"""Test when salsa is missing."""
|
|
with patch("debian_todo.shutil.which") as mock_which:
|
|
def which_side_effect(tool):
|
|
return None if tool == "salsa" else "/usr/bin/tool"
|
|
mock_which.side_effect = which_side_effect
|
|
with pytest.raises(debian_todo.MissingToolError) as exc_info:
|
|
debian_todo.check_required_tools()
|
|
assert "salsa" in str(exc_info.value)
|
|
|
|
def test_missing_gbp(self):
|
|
"""Test when gbp is missing."""
|
|
with patch("debian_todo.shutil.which") as mock_which:
|
|
def which_side_effect(tool):
|
|
return None if tool == "gbp" else "/usr/bin/tool"
|
|
mock_which.side_effect = which_side_effect
|
|
with pytest.raises(debian_todo.MissingToolError) as exc_info:
|
|
debian_todo.check_required_tools()
|
|
assert "gbp" in str(exc_info.value)
|
|
|
|
def test_missing_multiple_tools(self):
|
|
"""Test when multiple tools are missing."""
|
|
with patch("debian_todo.shutil.which") as mock_which:
|
|
mock_which.return_value = None
|
|
with pytest.raises(debian_todo.MissingToolError) as exc_info:
|
|
debian_todo.check_required_tools()
|
|
error_msg = str(exc_info.value)
|
|
assert "salsa" in error_msg
|
|
assert "gbp" in error_msg
|
|
assert "dch" in error_msg
|
|
|
|
|
|
class TestFetchTodoListErrors:
|
|
"""Tests for fetch_todo_list error handling."""
|
|
|
|
def test_http_error(self):
|
|
"""Test HTTP error handling."""
|
|
with patch("debian_todo.urlopen") as mock_urlopen:
|
|
mock_urlopen.side_effect = urllib.error.HTTPError(
|
|
debian_todo.TODO_URL, 404, "Not Found", {}, None
|
|
)
|
|
with pytest.raises(debian_todo.PackageUpdateError) as exc_info:
|
|
debian_todo.fetch_todo_list()
|
|
assert "HTTP error 404" in str(exc_info.value)
|
|
assert "Not Found" in str(exc_info.value)
|
|
|
|
def test_network_error(self):
|
|
"""Test network error handling."""
|
|
with patch("debian_todo.urlopen") as mock_urlopen:
|
|
mock_urlopen.side_effect = urllib.error.URLError("Connection refused")
|
|
with pytest.raises(debian_todo.PackageUpdateError) as exc_info:
|
|
debian_todo.fetch_todo_list()
|
|
assert "Network error" in str(exc_info.value)
|
|
assert "Connection refused" in str(exc_info.value)
|
|
|
|
def test_timeout_error(self):
|
|
"""Test timeout error handling."""
|
|
with patch("debian_todo.urlopen") as mock_urlopen:
|
|
mock_urlopen.side_effect = TimeoutError("Request timed out")
|
|
with pytest.raises(debian_todo.PackageUpdateError) as exc_info:
|
|
debian_todo.fetch_todo_list(timeout=10)
|
|
assert "Timeout after 10s" in str(exc_info.value)
|
|
|
|
def test_invalid_json(self):
|
|
"""Test invalid JSON response handling."""
|
|
with patch("debian_todo.urlopen") as mock_urlopen:
|
|
mock_response = MagicMock()
|
|
mock_response.read.return_value = b"not valid json {"
|
|
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 "Invalid JSON" in str(exc_info.value)
|
|
|
|
def test_successful_fetch(self):
|
|
"""Test successful fetch."""
|
|
with patch("debian_todo.urlopen") as mock_urlopen:
|
|
mock_response = MagicMock()
|
|
mock_response.read.return_value = b'[{"source": "pkg1"}]'
|
|
mock_response.__enter__ = lambda self: self
|
|
mock_response.__exit__ = lambda self, *args: None
|
|
mock_urlopen.return_value = mock_response
|
|
result = debian_todo.fetch_todo_list()
|
|
assert result == [{"source": "pkg1"}]
|
|
|
|
|
|
class TestTeamSlugToDisplayName:
|
|
"""Tests for team_slug_to_display_name function."""
|
|
|
|
def test_homeassistant_team(self):
|
|
assert debian_todo.team_slug_to_display_name("homeassistant-team") == "HA"
|
|
|
|
def test_python_team(self):
|
|
assert debian_todo.team_slug_to_display_name("python-team") == "python"
|
|
|
|
def test_openstack_team(self):
|
|
assert debian_todo.team_slug_to_display_name("openstack-team") == "openstack"
|
|
|
|
def test_other_team(self):
|
|
assert debian_todo.team_slug_to_display_name("rust-team") == "rust"
|
|
|
|
def test_no_team_suffix(self):
|
|
assert debian_todo.team_slug_to_display_name("username") == "username"
|
|
|
|
|
|
class TestHasUncommittedChanges:
|
|
"""Tests for has_uncommitted_changes function."""
|
|
|
|
def test_no_changes(self, tmp_path):
|
|
with patch("debian_todo.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(stdout="", returncode=0)
|
|
assert debian_todo.has_uncommitted_changes(tmp_path) is False
|
|
mock_run.assert_called_once_with(
|
|
["git", "status", "--porcelain"],
|
|
cwd=tmp_path,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
|
|
def test_has_changes(self, tmp_path):
|
|
with patch("debian_todo.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(stdout=" M file.txt\n", returncode=0)
|
|
assert debian_todo.has_uncommitted_changes(tmp_path) is True
|
|
|
|
def test_untracked_files(self, tmp_path):
|
|
with patch("debian_todo.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(stdout="?? newfile.txt\n", returncode=0)
|
|
assert debian_todo.has_uncommitted_changes(tmp_path) is True
|
|
|
|
def test_whitespace_only_output(self, tmp_path):
|
|
with patch("debian_todo.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(stdout=" \n", returncode=0)
|
|
assert debian_todo.has_uncommitted_changes(tmp_path) is False
|
|
|
|
|
|
class TestExtractUpstreamVersionFromGitLog:
|
|
"""Tests for extract_upstream_version_from_git_log function."""
|
|
|
|
def test_extracts_version(self, tmp_path):
|
|
git_log_output = """commit abc123
|
|
Author: Someone <someone@example.com>
|
|
Date: Mon Jan 1 12:00:00 2024 +0000
|
|
|
|
Update upstream source from tag 'upstream/1.2.3'
|
|
"""
|
|
with patch("debian_todo.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(
|
|
stdout=git_log_output, returncode=0
|
|
)
|
|
result = debian_todo.extract_upstream_version_from_git_log(tmp_path)
|
|
assert result == "1.2.3"
|
|
|
|
def test_extracts_complex_version(self, tmp_path):
|
|
git_log_output = """commit abc123
|
|
Author: Someone <someone@example.com>
|
|
Date: Mon Jan 1 12:00:00 2024 +0000
|
|
|
|
Update upstream source from tag 'upstream/2024.1.0rc1'
|
|
"""
|
|
with patch("debian_todo.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(
|
|
stdout=git_log_output, returncode=0
|
|
)
|
|
result = debian_todo.extract_upstream_version_from_git_log(tmp_path)
|
|
assert result == "2024.1.0rc1"
|
|
|
|
def test_no_upstream_tag(self, tmp_path):
|
|
git_log_output = """commit abc123
|
|
Author: Someone <someone@example.com>
|
|
Date: Mon Jan 1 12:00:00 2024 +0000
|
|
|
|
Fixed a bug
|
|
"""
|
|
with patch("debian_todo.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(
|
|
stdout=git_log_output, returncode=0
|
|
)
|
|
result = debian_todo.extract_upstream_version_from_git_log(tmp_path)
|
|
assert result is None
|
|
|
|
def test_git_log_fails(self, tmp_path):
|
|
with patch("debian_todo.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(stdout="", returncode=1)
|
|
result = debian_todo.extract_upstream_version_from_git_log(tmp_path)
|
|
assert result is None
|
|
|
|
|
|
class TestValidatePackageInfo:
|
|
"""Tests for validate_package_info function."""
|
|
|
|
def test_valid_package(self, monkeypatch):
|
|
"""Test validating a package that exists."""
|
|
monkeypatch.setattr(
|
|
debian_todo,
|
|
"load_source_info_map",
|
|
lambda: {"mypkg": {"vcs_git": "python-team", "uploaders": ""}},
|
|
)
|
|
result = debian_todo.validate_package_info("mypkg")
|
|
assert result == {"vcs_git": "python-team", "uploaders": ""}
|
|
|
|
def test_package_not_found(self, monkeypatch):
|
|
"""Test package not in source info map."""
|
|
monkeypatch.setattr(debian_todo, "load_source_info_map", lambda: {})
|
|
with pytest.raises(debian_todo.PackageNotFoundError) as exc_info:
|
|
debian_todo.validate_package_info("nonexistent")
|
|
assert "nonexistent" in str(exc_info.value)
|
|
|
|
def test_package_no_vcs_git(self, monkeypatch):
|
|
"""Test package with no vcs_git field."""
|
|
monkeypatch.setattr(
|
|
debian_todo,
|
|
"load_source_info_map",
|
|
lambda: {"mypkg": {"vcs_git": "", "uploaders": ""}},
|
|
)
|
|
with pytest.raises(debian_todo.PackageNotFoundError) as exc_info:
|
|
debian_todo.validate_package_info("mypkg")
|
|
assert "mypkg" in str(exc_info.value)
|
|
|
|
|
|
class TestResolvePackageDirectories:
|
|
"""Tests for resolve_package_directories function."""
|
|
|
|
def test_python_team_package(self, tmp_path, monkeypatch):
|
|
"""Test resolving directories for python-team package."""
|
|
monkeypatch.setattr(debian_todo, "DEBIAN_SRC_BASE", tmp_path)
|
|
source_info = {"vcs_git": "python-team", "uploaders": ""}
|
|
team_dir, pkg_dir, repo_dir = debian_todo.resolve_package_directories(
|
|
"mypkg", source_info
|
|
)
|
|
assert team_dir == tmp_path / "python"
|
|
assert pkg_dir == tmp_path / "python" / "mypkg"
|
|
assert repo_dir == tmp_path / "python" / "mypkg" / "mypkg"
|
|
|
|
def test_homeassistant_team_package(self, tmp_path, monkeypatch):
|
|
"""Test resolving directories for homeassistant-team package."""
|
|
monkeypatch.setattr(debian_todo, "DEBIAN_SRC_BASE", tmp_path)
|
|
source_info = {"vcs_git": "homeassistant-team", "uploaders": ""}
|
|
team_dir, pkg_dir, repo_dir = debian_todo.resolve_package_directories(
|
|
"ha-pkg", source_info
|
|
)
|
|
assert team_dir == tmp_path / "ha"
|
|
assert pkg_dir == tmp_path / "ha" / "ha-pkg"
|
|
assert repo_dir == tmp_path / "ha" / "ha-pkg" / "ha-pkg"
|
|
|
|
|
|
class TestEnsurePackageCheckout:
|
|
"""Tests for ensure_package_checkout function."""
|
|
|
|
def test_successful_checkout(self, tmp_path):
|
|
"""Test successful package checkout."""
|
|
pkg_dir = tmp_path / "python" / "mypkg"
|
|
source_info = {"vcs_git": "python-team", "uploaders": ""}
|
|
|
|
with patch("debian_todo.run_command") as mock_run:
|
|
debian_todo.ensure_package_checkout("mypkg", source_info, pkg_dir)
|
|
assert pkg_dir.exists()
|
|
mock_run.assert_called_once_with(
|
|
["salsa", "checkout", "python-team/deps/mypkg"],
|
|
pkg_dir,
|
|
"Checkout python-team/deps/mypkg",
|
|
)
|
|
|
|
def test_checkout_with_existing_parent(self, tmp_path):
|
|
"""Test checkout when parent directory already exists."""
|
|
pkg_dir = tmp_path / "python" / "mypkg"
|
|
pkg_dir.mkdir(parents=True)
|
|
source_info = {"vcs_git": "python-team", "uploaders": ""}
|
|
|
|
with patch("debian_todo.run_command") as mock_run:
|
|
debian_todo.ensure_package_checkout("mypkg", source_info, pkg_dir)
|
|
assert pkg_dir.exists()
|
|
|
|
|
|
class TestValidateRepositoryState:
|
|
"""Tests for validate_repository_state function."""
|
|
|
|
def test_clean_repository(self, tmp_path):
|
|
"""Test validating a clean repository."""
|
|
with patch("debian_todo.has_uncommitted_changes") as mock_has_changes:
|
|
mock_has_changes.return_value = False
|
|
debian_todo.validate_repository_state(tmp_path)
|
|
|
|
def test_dirty_repository(self, tmp_path):
|
|
"""Test validating a repository with uncommitted changes."""
|
|
with patch("debian_todo.has_uncommitted_changes") as mock_has_changes:
|
|
mock_has_changes.return_value = True
|
|
with pytest.raises(debian_todo.RepositoryStateError) as exc_info:
|
|
debian_todo.validate_repository_state(tmp_path)
|
|
assert "uncommitted changes" in str(exc_info.value)
|
|
|
|
|
|
class TestRunGbpPqWorkflow:
|
|
"""Tests for run_gbp_pq_workflow function."""
|
|
|
|
def test_successful_workflow(self, tmp_path):
|
|
"""Test successful gbp pq import and switch."""
|
|
with patch("debian_todo.run_command") as mock_run:
|
|
debian_todo.run_gbp_pq_workflow(tmp_path)
|
|
assert mock_run.call_count == 2
|
|
mock_run.assert_any_call(
|
|
["gbp", "pq", "import"], tmp_path, "Import patch queue"
|
|
)
|
|
mock_run.assert_any_call(
|
|
["gbp", "pq", "switch"], tmp_path, "Switch to patch branch"
|
|
)
|
|
|
|
def test_import_fails(self, tmp_path):
|
|
"""Test when gbp pq import fails."""
|
|
with patch("debian_todo.run_command") as mock_run:
|
|
mock_run.side_effect = debian_todo.ExternalCommandError(
|
|
"gbp pq import", 1, "Import failed"
|
|
)
|
|
with pytest.raises(debian_todo.ExternalCommandError):
|
|
debian_todo.run_gbp_pq_workflow(tmp_path)
|
|
|
|
|
|
class TestImportUpstreamVersion:
|
|
"""Tests for import_upstream_version function."""
|
|
|
|
def test_successful_import(self, tmp_path, capsys):
|
|
"""Test successful upstream import."""
|
|
with patch("debian_todo.run_command") as mock_run:
|
|
debian_todo.import_upstream_version(tmp_path)
|
|
mock_run.assert_called_once_with(
|
|
["gbp", "import-orig", "--uscan", "--pristine-tar", "--no-interactive"],
|
|
tmp_path,
|
|
"Import upstream version",
|
|
)
|
|
captured = capsys.readouterr()
|
|
assert "Importing new upstream version" in captured.out
|
|
|
|
def test_import_fails(self, tmp_path):
|
|
"""Test when upstream import fails."""
|
|
with patch("debian_todo.run_command") as mock_run:
|
|
mock_run.side_effect = debian_todo.ExternalCommandError(
|
|
"gbp import-orig", 1, "Import failed"
|
|
)
|
|
with pytest.raises(debian_todo.ExternalCommandError):
|
|
debian_todo.import_upstream_version(tmp_path)
|
|
|
|
|
|
class TestRunPackageUpdates:
|
|
"""Tests for run_package_updates function."""
|
|
|
|
def test_runs_all_updates(self, tmp_path):
|
|
"""Test that all update functions are called."""
|
|
with patch("debian_todo.update_debian_control") as mock_control:
|
|
with patch("debian_todo.update_debian_copyright_year") as mock_copyright:
|
|
with patch("debian_todo.update_debian_watch") as mock_watch:
|
|
with patch("debian_todo.add_salsa_ci") as mock_salsa:
|
|
debian_todo.run_package_updates(tmp_path)
|
|
mock_control.assert_called_once_with(tmp_path)
|
|
mock_copyright.assert_called_once_with(tmp_path)
|
|
mock_watch.assert_called_once_with(tmp_path)
|
|
mock_salsa.assert_called_once_with(tmp_path)
|
|
|
|
|
|
class TestUpdateDebianControl:
|
|
"""Tests for update_debian_control function."""
|
|
|
|
def test_removes_priority_optional(self, tmp_path):
|
|
"""Test removing obsolete Priority: optional."""
|
|
debian_dir = tmp_path / "debian"
|
|
debian_dir.mkdir()
|
|
control_path = debian_dir / "control"
|
|
control_path.write_text("Source: mypkg\nPriority: optional\n")
|
|
|
|
with patch("debian_todo.run_command") as mock_run:
|
|
debian_todo.update_debian_control(tmp_path)
|
|
mock_run.assert_called_once()
|
|
assert "Priority: optional" in mock_run.call_args[0][0][2]
|
|
|
|
# Verify Priority: optional was removed
|
|
content = control_path.read_text()
|
|
assert "Priority: optional" not in content
|
|
|
|
def test_updates_standards_version(self, tmp_path):
|
|
"""Test updating Standards-Version."""
|
|
debian_dir = tmp_path / "debian"
|
|
debian_dir.mkdir()
|
|
control_path = debian_dir / "control"
|
|
control_path.write_text("Source: mypkg\nStandards-Version: 4.6.0\n")
|
|
|
|
with patch("debian_todo.run_command") as mock_run:
|
|
debian_todo.update_debian_control(tmp_path)
|
|
mock_run.assert_called_once()
|
|
assert "Standards-Version" in mock_run.call_args[0][0][2]
|
|
|
|
# Verify Standards-Version was updated
|
|
content = control_path.read_text()
|
|
assert "Standards-Version: 4.7.3" in content
|
|
|
|
def test_no_changes_needed(self, tmp_path):
|
|
"""Test when no changes are needed."""
|
|
debian_dir = tmp_path / "debian"
|
|
debian_dir.mkdir()
|
|
control_path = debian_dir / "control"
|
|
control_path.write_text("Source: mypkg\nStandards-Version: 4.7.3\n")
|
|
|
|
with patch("debian_todo.run_command") as mock_run:
|
|
debian_todo.update_debian_control(tmp_path)
|
|
mock_run.assert_not_called()
|
|
|
|
def test_missing_control_file(self, tmp_path):
|
|
"""Test when debian/control doesn't exist."""
|
|
debian_todo.update_debian_control(tmp_path) # Should not raise
|
|
|
|
|
|
class TestUpdateDebianCopyrightYear:
|
|
"""Tests for update_debian_copyright_year function."""
|
|
|
|
def test_updates_single_year(self, tmp_path):
|
|
"""Test updating a single year to a range."""
|
|
debian_dir = tmp_path / "debian"
|
|
debian_dir.mkdir()
|
|
copyright_path = debian_dir / "copyright"
|
|
current_year = datetime.date.today().year
|
|
last_year = current_year - 1
|
|
copyright_path.write_text(
|
|
f"Copyright: {last_year} Edward Betts <edward@4angle.com>\n"
|
|
)
|
|
|
|
with patch("debian_todo.run_command") as mock_run:
|
|
debian_todo.update_debian_copyright_year(tmp_path)
|
|
mock_run.assert_called_once()
|
|
|
|
# Verify year was updated
|
|
content = copyright_path.read_text()
|
|
assert f"{last_year}-{current_year}" in content
|
|
|
|
def test_updates_year_range(self, tmp_path):
|
|
"""Test extending a year range."""
|
|
debian_dir = tmp_path / "debian"
|
|
debian_dir.mkdir()
|
|
copyright_path = debian_dir / "copyright"
|
|
current_year = datetime.date.today().year
|
|
copyright_path.write_text(
|
|
f"Copyright: 2020-{current_year - 2} Edward Betts <edward@4angle.com>\n"
|
|
)
|
|
|
|
with patch("debian_todo.run_command") as mock_run:
|
|
debian_todo.update_debian_copyright_year(tmp_path)
|
|
mock_run.assert_called_once()
|
|
|
|
# Verify year range was updated
|
|
content = copyright_path.read_text()
|
|
assert f"2020-{current_year}" in content
|
|
|
|
def test_no_update_needed(self, tmp_path):
|
|
"""Test when copyright year is already current."""
|
|
debian_dir = tmp_path / "debian"
|
|
debian_dir.mkdir()
|
|
copyright_path = debian_dir / "copyright"
|
|
current_year = datetime.date.today().year
|
|
copyright_path.write_text(
|
|
f"Copyright: {current_year} Edward Betts <edward@4angle.com>\n"
|
|
)
|
|
|
|
with patch("debian_todo.run_command") as mock_run:
|
|
debian_todo.update_debian_copyright_year(tmp_path)
|
|
mock_run.assert_not_called()
|
|
|
|
def test_missing_copyright_file(self, tmp_path):
|
|
"""Test when debian/copyright doesn't exist."""
|
|
debian_todo.update_debian_copyright_year(tmp_path) # Should not raise
|
|
|
|
|
|
class TestUpdateDebianWatch:
|
|
"""Tests for update_debian_watch function."""
|
|
|
|
def test_upgrades_github_watch(self, tmp_path):
|
|
"""Test upgrading a GitHub watch file."""
|
|
debian_dir = tmp_path / "debian"
|
|
debian_dir.mkdir()
|
|
watch_path = debian_dir / "watch"
|
|
watch_path.write_text(
|
|
"version=4\n"
|
|
"https://github.com/owner/repo/releases .*/v?([\\d.]+)\\.tar\\.gz\n"
|
|
)
|
|
|
|
with patch("debian_todo.run_command") as mock_run:
|
|
debian_todo.update_debian_watch(tmp_path)
|
|
mock_run.assert_called_once()
|
|
|
|
# Verify watch was upgraded
|
|
content = watch_path.read_text()
|
|
assert "Version: 5" in content
|
|
assert "Template: GitHub" in content
|
|
assert "Owner: owner" in content
|
|
assert "Project: repo" in content
|
|
|
|
def test_upgrades_pypi_watch(self, tmp_path):
|
|
"""Test upgrading a PyPI watch file."""
|
|
debian_dir = tmp_path / "debian"
|
|
debian_dir.mkdir()
|
|
watch_path = debian_dir / "watch"
|
|
watch_path.write_text(
|
|
"version=4\n"
|
|
"https://pypi.debian.net/mypackage/mypackage-([\\d.]+)\\.tar\\.gz\n"
|
|
)
|
|
|
|
with patch("debian_todo.run_command") as mock_run:
|
|
debian_todo.update_debian_watch(tmp_path)
|
|
mock_run.assert_called_once()
|
|
|
|
# Verify watch was upgraded
|
|
content = watch_path.read_text()
|
|
assert "Version: 5" in content
|
|
assert "Template: Pypi" in content
|
|
assert "Dist: mypackage" in content
|
|
|
|
def test_already_version_5(self, tmp_path):
|
|
"""Test when watch is already version 5."""
|
|
debian_dir = tmp_path / "debian"
|
|
debian_dir.mkdir()
|
|
watch_path = debian_dir / "watch"
|
|
watch_path.write_text("Version: 5\n\nTemplate: GitHub\n")
|
|
|
|
with patch("debian_todo.run_command") as mock_run:
|
|
debian_todo.update_debian_watch(tmp_path)
|
|
mock_run.assert_not_called()
|
|
|
|
def test_missing_watch_file(self, tmp_path):
|
|
"""Test when debian/watch doesn't exist."""
|
|
debian_todo.update_debian_watch(tmp_path) # Should not raise
|
|
|
|
|
|
class TestAddSalsaCi:
|
|
"""Tests for add_salsa_ci function."""
|
|
|
|
def test_adds_salsa_ci_yml(self, tmp_path):
|
|
"""Test adding debian/salsa-ci.yml."""
|
|
debian_dir = tmp_path / "debian"
|
|
debian_dir.mkdir()
|
|
control_path = debian_dir / "control"
|
|
control_path.write_text(
|
|
"Source: mypkg\n"
|
|
"Vcs-Git: https://salsa.debian.org/python-team/packages/mypkg.git\n"
|
|
)
|
|
|
|
with patch("debian_todo.run_command") as mock_run:
|
|
debian_todo.add_salsa_ci(tmp_path)
|
|
assert mock_run.call_count == 2 # git add and dch
|
|
|
|
# Verify file was created
|
|
salsa_ci_path = debian_dir / "salsa-ci.yml"
|
|
assert salsa_ci_path.exists()
|
|
content = salsa_ci_path.read_text()
|
|
assert "salsa-ci-team/pipeline" in content
|
|
|
|
def test_already_exists(self, tmp_path):
|
|
"""Test when debian/salsa-ci.yml already exists."""
|
|
debian_dir = tmp_path / "debian"
|
|
debian_dir.mkdir()
|
|
salsa_ci_path = debian_dir / "salsa-ci.yml"
|
|
salsa_ci_path.write_text("existing content\n")
|
|
|
|
with patch("debian_todo.run_command") as mock_run:
|
|
debian_todo.add_salsa_ci(tmp_path)
|
|
mock_run.assert_not_called()
|
|
|
|
def test_missing_control_file(self, tmp_path):
|
|
"""Test when debian/control doesn't exist."""
|
|
debian_todo.add_salsa_ci(tmp_path) # Should not raise
|
|
|
|
|
|
class TestAddChangelogEntry:
|
|
"""Tests for add_changelog_entry function."""
|
|
|
|
def test_adds_changelog_entry(self, tmp_path):
|
|
with patch.object(
|
|
debian_todo, "extract_upstream_version_from_git_log"
|
|
) as mock_extract:
|
|
mock_extract.return_value = "1.2.3"
|
|
with patch("debian_todo.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=0)
|
|
result = debian_todo.add_changelog_entry(tmp_path)
|
|
assert result is True
|
|
mock_run.assert_called_once_with(
|
|
[
|
|
"dch",
|
|
"--release-heuristic",
|
|
"log",
|
|
"--newversion",
|
|
"1.2.3-1",
|
|
"New upstream release.",
|
|
],
|
|
cwd=tmp_path,
|
|
)
|
|
|
|
def test_dch_fails(self, tmp_path):
|
|
with patch.object(
|
|
debian_todo, "extract_upstream_version_from_git_log"
|
|
) as mock_extract:
|
|
mock_extract.return_value = "1.2.3"
|
|
with patch("debian_todo.subprocess.run") as mock_run:
|
|
mock_run.return_value = MagicMock(returncode=1)
|
|
result = debian_todo.add_changelog_entry(tmp_path)
|
|
assert result is False
|
|
|
|
def test_no_version_found(self, tmp_path):
|
|
with patch.object(
|
|
debian_todo, "extract_upstream_version_from_git_log"
|
|
) as mock_extract:
|
|
mock_extract.return_value = None
|
|
result = debian_todo.add_changelog_entry(tmp_path)
|
|
assert result is False
|
|
|
|
|
|
class TestUpdatePackage:
|
|
"""Tests for update_package function."""
|
|
|
|
def test_package_not_found(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
debian_todo, "load_source_info_map", lambda: {}
|
|
)
|
|
with pytest.raises(debian_todo.PackageNotFoundError) as exc_info:
|
|
debian_todo.update_package("nonexistent-pkg")
|
|
assert "nonexistent-pkg" in str(exc_info.value)
|
|
|
|
def test_package_no_vcs_git(self, monkeypatch):
|
|
monkeypatch.setattr(
|
|
debian_todo,
|
|
"load_source_info_map",
|
|
lambda: {"mypkg": {"vcs_git": "", "uploaders": ""}},
|
|
)
|
|
with pytest.raises(debian_todo.PackageNotFoundError) as exc_info:
|
|
debian_todo.update_package("mypkg")
|
|
assert "mypkg" in str(exc_info.value)
|
|
|
|
def test_new_checkout(self, tmp_path, monkeypatch):
|
|
"""Test checking out a package that doesn't exist yet."""
|
|
monkeypatch.setattr(
|
|
debian_todo,
|
|
"load_source_info_map",
|
|
lambda: {"mypkg": {"vcs_git": "python-team", "uploaders": ""}},
|
|
)
|
|
monkeypatch.setattr(debian_todo, "DEBIAN_SRC_BASE", tmp_path)
|
|
|
|
subprocess_calls = []
|
|
|
|
def mock_subprocess_run(cmd, **kwargs):
|
|
subprocess_calls.append((cmd, kwargs))
|
|
result = MagicMock(returncode=0, stdout="", stderr="")
|
|
# After salsa checkout, create the repo directory
|
|
if cmd[0] == "salsa":
|
|
pkg_dir = tmp_path / "python" / "mypkg"
|
|
repo_dir = pkg_dir / "mypkg"
|
|
repo_dir.mkdir(parents=True, exist_ok=True)
|
|
return result
|
|
|
|
monkeypatch.setattr(debian_todo.subprocess, "run", mock_subprocess_run)
|
|
monkeypatch.setattr(
|
|
debian_todo,
|
|
"extract_upstream_version_from_git_log",
|
|
lambda _: "1.0.0",
|
|
)
|
|
monkeypatch.setattr(debian_todo.shutil, "which", lambda _: "/usr/bin/tool")
|
|
|
|
# Mock the update functions to avoid needing debian/ directory
|
|
with patch("debian_todo.run_package_updates"):
|
|
debian_todo.update_package("mypkg")
|
|
|
|
# Check salsa checkout was called
|
|
assert subprocess_calls[0][0] == ["salsa", "checkout", "python-team/deps/mypkg"]
|
|
# Check gbp pq commands were called
|
|
assert subprocess_calls[1][0] == ["gbp", "pq", "import"]
|
|
assert subprocess_calls[2][0] == ["gbp", "pq", "switch"]
|
|
# Check gbp import-orig was called
|
|
assert subprocess_calls[3][0] == [
|
|
"gbp", "import-orig", "--uscan", "--pristine-tar", "--no-interactive"
|
|
]
|
|
# Check dch was called
|
|
assert subprocess_calls[4][0][0] == "dch"
|
|
|
|
def test_existing_checkout_clean(self, tmp_path, monkeypatch):
|
|
"""Test updating a package that's already checked out and clean."""
|
|
monkeypatch.setattr(
|
|
debian_todo,
|
|
"load_source_info_map",
|
|
lambda: {"mypkg": {"vcs_git": "homeassistant-team", "uploaders": ""}},
|
|
)
|
|
monkeypatch.setattr(debian_todo, "DEBIAN_SRC_BASE", tmp_path)
|
|
|
|
# Create existing repo directory
|
|
repo_dir = tmp_path / "ha" / "mypkg" / "mypkg"
|
|
repo_dir.mkdir(parents=True)
|
|
|
|
subprocess_calls = []
|
|
|
|
def mock_subprocess_run(cmd, **kwargs):
|
|
subprocess_calls.append((cmd, kwargs))
|
|
if cmd == ["git", "status", "--porcelain"]:
|
|
return MagicMock(stdout="", returncode=0)
|
|
return MagicMock(returncode=0, stdout="", stderr="")
|
|
|
|
monkeypatch.setattr(debian_todo.subprocess, "run", mock_subprocess_run)
|
|
monkeypatch.setattr(
|
|
debian_todo,
|
|
"extract_upstream_version_from_git_log",
|
|
lambda _: "2.0.0",
|
|
)
|
|
monkeypatch.setattr(debian_todo.shutil, "which", lambda _: "/usr/bin/tool")
|
|
|
|
# Mock the update functions to avoid needing debian/ directory
|
|
with patch("debian_todo.run_package_updates"):
|
|
debian_todo.update_package("mypkg")
|
|
|
|
# Should not call salsa checkout
|
|
salsa_calls = [c for c in subprocess_calls if c[0][0] == "salsa"]
|
|
assert len(salsa_calls) == 0
|
|
|
|
# Should call git status, gbp pq, gbp import-orig, dch
|
|
assert subprocess_calls[0][0] == ["git", "status", "--porcelain"]
|
|
assert subprocess_calls[1][0] == ["gbp", "pq", "import"]
|
|
assert subprocess_calls[2][0] == ["gbp", "pq", "switch"]
|
|
assert subprocess_calls[3][0][0] == "gbp"
|
|
assert subprocess_calls[4][0][0] == "dch"
|
|
|
|
def test_existing_checkout_dirty(self, tmp_path, monkeypatch):
|
|
"""Test that update aborts if there are uncommitted changes."""
|
|
monkeypatch.setattr(
|
|
debian_todo,
|
|
"load_source_info_map",
|
|
lambda: {"mypkg": {"vcs_git": "python-team", "uploaders": ""}},
|
|
)
|
|
monkeypatch.setattr(debian_todo, "DEBIAN_SRC_BASE", tmp_path)
|
|
|
|
# Create existing repo directory
|
|
repo_dir = tmp_path / "python" / "mypkg" / "mypkg"
|
|
repo_dir.mkdir(parents=True)
|
|
|
|
def mock_subprocess_run(cmd, **kwargs):
|
|
if cmd == ["git", "status", "--porcelain"]:
|
|
return MagicMock(stdout=" M dirty.txt\n", returncode=0)
|
|
return MagicMock(returncode=0)
|
|
|
|
monkeypatch.setattr(debian_todo.subprocess, "run", mock_subprocess_run)
|
|
monkeypatch.setattr(debian_todo.shutil, "which", lambda _: "/usr/bin/tool")
|
|
|
|
with pytest.raises(debian_todo.RepositoryStateError) as exc_info:
|
|
debian_todo.update_package("mypkg")
|
|
|
|
assert "uncommitted changes" in str(exc_info.value)
|
|
|
|
def test_salsa_checkout_fails(self, tmp_path, monkeypatch):
|
|
"""Test error handling when salsa checkout fails."""
|
|
monkeypatch.setattr(
|
|
debian_todo,
|
|
"load_source_info_map",
|
|
lambda: {"mypkg": {"vcs_git": "python-team", "uploaders": ""}},
|
|
)
|
|
monkeypatch.setattr(debian_todo, "DEBIAN_SRC_BASE", tmp_path)
|
|
|
|
def mock_subprocess_run(cmd, **kwargs):
|
|
if cmd[0] == "salsa":
|
|
return MagicMock(returncode=1, stdout="", stderr="")
|
|
return MagicMock(returncode=0, stdout="", stderr="")
|
|
|
|
monkeypatch.setattr(debian_todo.subprocess, "run", mock_subprocess_run)
|
|
monkeypatch.setattr(debian_todo.shutil, "which", lambda _: "/usr/bin/tool")
|
|
|
|
with pytest.raises(debian_todo.ExternalCommandError) as exc_info:
|
|
debian_todo.update_package("mypkg")
|
|
|
|
assert "salsa" in str(exc_info.value)
|
|
|
|
def test_gbp_import_fails(self, tmp_path, monkeypatch):
|
|
"""Test error handling when gbp import-orig fails."""
|
|
monkeypatch.setattr(
|
|
debian_todo,
|
|
"load_source_info_map",
|
|
lambda: {"mypkg": {"vcs_git": "python-team", "uploaders": ""}},
|
|
)
|
|
monkeypatch.setattr(debian_todo, "DEBIAN_SRC_BASE", tmp_path)
|
|
|
|
# Create existing repo directory
|
|
repo_dir = tmp_path / "python" / "mypkg" / "mypkg"
|
|
repo_dir.mkdir(parents=True)
|
|
|
|
call_count = {"count": 0}
|
|
|
|
def mock_subprocess_run(cmd, **kwargs):
|
|
if cmd == ["git", "status", "--porcelain"]:
|
|
return MagicMock(stdout="", returncode=0)
|
|
if cmd[0] == "gbp":
|
|
call_count["count"] += 1
|
|
# Fail on third gbp call (import-orig), succeed on pq commands
|
|
if call_count["count"] >= 3:
|
|
return MagicMock(returncode=1, stdout="", stderr="")
|
|
return MagicMock(returncode=0, stdout="", stderr="")
|
|
|
|
monkeypatch.setattr(debian_todo.subprocess, "run", mock_subprocess_run)
|
|
monkeypatch.setattr(debian_todo.shutil, "which", lambda _: "/usr/bin/tool")
|
|
|
|
with pytest.raises(debian_todo.ExternalCommandError) as exc_info:
|
|
debian_todo.update_package("mypkg")
|
|
|
|
assert "gbp" in str(exc_info.value)
|
|
|
|
def test_changelog_entry_fails(self, tmp_path, monkeypatch):
|
|
"""Test error handling when adding changelog entry fails."""
|
|
monkeypatch.setattr(
|
|
debian_todo,
|
|
"load_source_info_map",
|
|
lambda: {"mypkg": {"vcs_git": "python-team", "uploaders": ""}},
|
|
)
|
|
monkeypatch.setattr(debian_todo, "DEBIAN_SRC_BASE", tmp_path)
|
|
|
|
# Create existing repo directory
|
|
repo_dir = tmp_path / "python" / "mypkg" / "mypkg"
|
|
repo_dir.mkdir(parents=True)
|
|
|
|
def mock_subprocess_run(cmd, **kwargs):
|
|
if cmd == ["git", "status", "--porcelain"]:
|
|
return MagicMock(stdout="", returncode=0)
|
|
return MagicMock(returncode=0, stdout="", stderr="")
|
|
|
|
monkeypatch.setattr(debian_todo.subprocess, "run", mock_subprocess_run)
|
|
monkeypatch.setattr(
|
|
debian_todo,
|
|
"extract_upstream_version_from_git_log",
|
|
lambda _: None,
|
|
)
|
|
monkeypatch.setattr(debian_todo.shutil, "which", lambda _: "/usr/bin/tool")
|
|
|
|
with pytest.raises(debian_todo.ExternalCommandError) as exc_info:
|
|
debian_todo.update_package("mypkg")
|
|
|
|
assert "upstream version" in str(exc_info.value)
|