pypi-query-mcp/tests/test_publishing.py
Ryan Malloy 9f3fd459b3 Implement PyPI Account & Publishing Tools
Add comprehensive PyPI publishing and account management functionality:

Features:
- upload_package_to_pypi: Upload distributions to PyPI/TestPyPI with safety checks
- check_pypi_credentials: Validate API tokens and credentials
- get_pypi_upload_history: View upload history for packages with statistics
- delete_pypi_release: Safe release deletion with dry-run and confirmation
- manage_pypi_maintainers: Add/remove/list package maintainers
- get_pypi_account_info: View account details, quotas, and limits

Implementation:
- Created pypi_query_mcp/tools/publishing.py with all 6 functions
- Added PyPIPublishingClient for authenticated API operations
- Comprehensive error handling with custom exceptions
- Full async/await patterns following existing codebase conventions
- Safety checks for destructive operations (deletion requires confirmation)
- Support for both production PyPI and TestPyPI

Integration:
- Added publishing-specific exceptions to core/exceptions.py
- Updated tools/__init__.py with publishing function imports
- Added 6 MCP server endpoints to server.py with proper error handling
- Created comprehensive tests in tests/test_publishing.py

Production-ready code with proper authentication, validation, and safety measures.
2025-08-16 08:52:03 -06:00

848 lines
33 KiB
Python

"""Tests for PyPI publishing and account management tools."""
import asyncio
import json
import os
import tempfile
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import httpx
import pytest
from pypi_query_mcp.core.exceptions import (
InvalidPackageNameError,
NetworkError,
PackageNotFoundError,
PyPIAuthenticationError,
PyPIPermissionError,
PyPIServerError,
PyPIUploadError,
RateLimitError,
)
from pypi_query_mcp.tools.publishing import (
PyPIPublishingClient,
check_pypi_credentials,
delete_pypi_release,
get_pypi_account_info,
get_pypi_upload_history,
manage_pypi_maintainers,
upload_package_to_pypi,
)
class TestPyPIPublishingClient:
"""Test cases for PyPIPublishingClient."""
def test_init_default(self):
"""Test client initialization with default values."""
client = PyPIPublishingClient()
assert client.api_token is None
assert client.test_pypi is False
assert client.timeout == 60.0
assert client.max_retries == 3
assert client.retry_delay == 2.0
assert "upload.pypi.org" in client.upload_url
assert "pypi.org" in client.api_url
def test_init_test_pypi(self):
"""Test client initialization for TestPyPI."""
client = PyPIPublishingClient(test_pypi=True)
assert client.test_pypi is True
assert "test.pypi.org" in client.upload_url
assert "test.pypi.org" in client.api_url
def test_init_with_token(self):
"""Test client initialization with API token."""
token = "pypi-test-token"
client = PyPIPublishingClient(api_token=token)
assert client.api_token == token
assert "Authorization" in client._client.headers
assert client._client.headers["Authorization"] == f"token {token}"
def test_validate_package_name_valid(self):
"""Test package name validation with valid names."""
client = PyPIPublishingClient()
valid_names = [
"requests",
"django-rest-framework",
"numpy",
"package_name",
"package.name",
"a",
"a1",
"package-1.0",
]
for name in valid_names:
result = client._validate_package_name(name)
assert result == name.strip()
def test_validate_package_name_invalid(self):
"""Test package name validation with invalid names."""
client = PyPIPublishingClient()
invalid_names = [
"",
" ",
"-invalid",
"invalid-",
".invalid",
"invalid.",
"in..valid",
"in--valid",
"in valid",
]
for name in invalid_names:
with pytest.raises(InvalidPackageNameError):
client._validate_package_name(name)
@pytest.mark.asyncio
async def test_make_request_success(self):
"""Test successful HTTP request."""
with patch.object(httpx.AsyncClient, 'request') as mock_request:
mock_response = Mock()
mock_response.status_code = 200
mock_request.return_value = mock_response
client = PyPIPublishingClient()
response = await client._make_request("GET", "https://example.com")
assert response == mock_response
mock_request.assert_called_once_with("GET", "https://example.com")
@pytest.mark.asyncio
async def test_make_request_authentication_error(self):
"""Test HTTP request with authentication error."""
with patch.object(httpx.AsyncClient, 'request') as mock_request:
mock_response = Mock()
mock_response.status_code = 401
mock_request.return_value = mock_response
client = PyPIPublishingClient()
with pytest.raises(PyPIAuthenticationError):
await client._make_request("GET", "https://example.com")
@pytest.mark.asyncio
async def test_make_request_permission_error(self):
"""Test HTTP request with permission error."""
with patch.object(httpx.AsyncClient, 'request') as mock_request:
mock_response = Mock()
mock_response.status_code = 403
mock_request.return_value = mock_response
client = PyPIPublishingClient()
with pytest.raises(PyPIPermissionError):
await client._make_request("GET", "https://example.com")
@pytest.mark.asyncio
async def test_make_request_rate_limit_error(self):
"""Test HTTP request with rate limit error."""
with patch.object(httpx.AsyncClient, 'request') as mock_request:
mock_response = Mock()
mock_response.status_code = 429
mock_response.headers = {"Retry-After": "60"}
mock_request.return_value = mock_response
client = PyPIPublishingClient()
with pytest.raises(RateLimitError) as exc_info:
await client._make_request("GET", "https://example.com")
assert exc_info.value.retry_after == 60
@pytest.mark.asyncio
async def test_make_request_network_error_with_retry(self):
"""Test HTTP request with network error and retry logic."""
with patch.object(httpx.AsyncClient, 'request') as mock_request:
mock_request.side_effect = httpx.NetworkError("Connection failed")
client = PyPIPublishingClient(max_retries=1, retry_delay=0.01)
with pytest.raises(NetworkError):
await client._make_request("GET", "https://example.com")
# Should retry once (initial + 1 retry = 2 calls)
assert mock_request.call_count == 2
@pytest.mark.asyncio
async def test_context_manager(self):
"""Test client as async context manager."""
with patch.object(PyPIPublishingClient, 'close') as mock_close:
async with PyPIPublishingClient() as client:
assert client is not None
mock_close.assert_called_once()
class TestUploadPackageToPyPI:
"""Test cases for upload_package_to_pypi function."""
@pytest.fixture
def temp_dist_files(self):
"""Create temporary distribution files for testing."""
with tempfile.TemporaryDirectory() as temp_dir:
temp_path = Path(temp_dir)
# Create fake distribution files
wheel_file = temp_path / "test_package-1.0.0-py3-none-any.whl"
sdist_file = temp_path / "test_package-1.0.0.tar.gz"
wheel_file.write_bytes(b"fake wheel content")
sdist_file.write_bytes(b"fake sdist content")
yield [str(wheel_file), str(sdist_file)]
@pytest.mark.asyncio
async def test_upload_no_distribution_paths(self):
"""Test upload with no distribution paths."""
with pytest.raises(ValueError, match="No distribution paths provided"):
await upload_package_to_pypi([])
@pytest.mark.asyncio
async def test_upload_missing_files(self):
"""Test upload with missing distribution files."""
missing_files = ["/nonexistent/file1.whl", "/nonexistent/file2.tar.gz"]
with pytest.raises(FileNotFoundError):
await upload_package_to_pypi(missing_files)
@pytest.mark.asyncio
async def test_upload_invalid_files(self, temp_dist_files):
"""Test upload with invalid file types."""
temp_dir = Path(temp_dist_files[0]).parent
invalid_file = temp_dir / "invalid.txt"
invalid_file.write_text("not a distribution file")
# Should skip invalid files and proceed with valid ones
with patch('pypi_query_mcp.tools.publishing.check_pypi_credentials') as mock_cred:
mock_cred.return_value = {"valid": True}
with patch.object(PyPIPublishingClient, '_make_request') as mock_request:
mock_response = Mock()
mock_response.status_code = 200
mock_request.return_value = mock_response
result = await upload_package_to_pypi(
temp_dist_files + [str(invalid_file)],
test_pypi=True
)
# Should only process the 2 valid distribution files
assert result["total_files"] == 2
@pytest.mark.asyncio
async def test_upload_authentication_failure(self, temp_dist_files):
"""Test upload with authentication failure."""
with patch('pypi_query_mcp.tools.publishing.check_pypi_credentials') as mock_cred:
mock_cred.return_value = {"valid": False}
with pytest.raises(PyPIAuthenticationError):
await upload_package_to_pypi(temp_dist_files, api_token="invalid-token")
@pytest.mark.asyncio
async def test_upload_successful(self, temp_dist_files):
"""Test successful upload."""
with patch('pypi_query_mcp.tools.publishing.check_pypi_credentials') as mock_cred:
mock_cred.return_value = {"valid": True}
with patch.object(PyPIPublishingClient, '_make_request') as mock_request:
mock_response = Mock()
mock_response.status_code = 200
mock_request.return_value = mock_response
result = await upload_package_to_pypi(
temp_dist_files,
api_token="valid-token",
test_pypi=True
)
assert result["successful_uploads"] == 2
assert result["failed_uploads"] == 0
assert result["target_repository"] == "TestPyPI"
assert len(result["upload_results"]) == 2
for upload_result in result["upload_results"]:
assert upload_result["status"] == "success"
@pytest.mark.asyncio
async def test_upload_file_exists_skip(self, temp_dist_files):
"""Test upload with existing file and skip_existing=True."""
with patch('pypi_query_mcp.tools.publishing.check_pypi_credentials') as mock_cred:
mock_cred.return_value = {"valid": True}
with patch.object(PyPIPublishingClient, '_make_request') as mock_request:
mock_response = Mock()
mock_response.status_code = 409 # Conflict - file exists
mock_request.return_value = mock_response
result = await upload_package_to_pypi(
temp_dist_files,
skip_existing=True,
test_pypi=True
)
assert result["successful_uploads"] == 0
assert result["skipped_uploads"] == 2
for upload_result in result["upload_results"]:
assert upload_result["status"] == "skipped"
assert "already exists" in upload_result["error"]
@pytest.mark.asyncio
async def test_upload_with_verification(self, temp_dist_files):
"""Test upload with verification enabled."""
with patch('pypi_query_mcp.tools.publishing.check_pypi_credentials') as mock_cred:
mock_cred.return_value = {"valid": True}
with patch.object(PyPIPublishingClient, '_make_request') as mock_request:
# Mock upload response
upload_response = Mock()
upload_response.status_code = 200
# Mock verification response
verify_response = Mock()
verify_response.status_code = 200
mock_request.side_effect = [upload_response, upload_response, verify_response, verify_response]
result = await upload_package_to_pypi(
temp_dist_files,
verify_uploads=True,
test_pypi=True
)
assert result["successful_uploads"] == 2
assert "verification_results" in result
assert len(result["verification_results"]) == 2
class TestCheckPyPICredentials:
"""Test cases for check_pypi_credentials function."""
@pytest.mark.asyncio
async def test_no_token_provided(self):
"""Test credential check with no token provided."""
with patch.dict(os.environ, {}, clear=True):
result = await check_pypi_credentials()
assert result["valid"] is False
assert "No API token provided" in result["error"]
@pytest.mark.asyncio
async def test_invalid_token_format(self):
"""Test credential check with invalid token format."""
result = await check_pypi_credentials(api_token="invalid-format")
assert result["valid"] is False
assert "Invalid token format" in result["error"]
@pytest.mark.asyncio
async def test_valid_credentials(self):
"""Test credential check with valid credentials."""
with patch.object(PyPIPublishingClient, '_make_request') as mock_request:
mock_response = Mock()
mock_response.status_code = 200
mock_request.return_value = mock_response
result = await check_pypi_credentials(
api_token="pypi-valid-token",
test_pypi=True
)
assert result["valid"] is True
assert result["repository"] == "TestPyPI"
@pytest.mark.asyncio
async def test_invalid_credentials(self):
"""Test credential check with invalid credentials."""
with patch.object(PyPIPublishingClient, '_make_request') as mock_request:
mock_request.side_effect = PyPIAuthenticationError("Invalid token", 401)
result = await check_pypi_credentials(api_token="pypi-invalid-token")
assert result["valid"] is False
assert result["status_code"] == 401
@pytest.mark.asyncio
async def test_permission_denied(self):
"""Test credential check with permission denied."""
with patch.object(PyPIPublishingClient, '_make_request') as mock_request:
mock_response = Mock()
mock_response.status_code = 403
mock_request.return_value = mock_response
result = await check_pypi_credentials(api_token="pypi-limited-token")
assert result["valid"] is False
assert result["status_code"] == 403
assert "permissions" in result["error"]
class TestGetPyPIUploadHistory:
"""Test cases for get_pypi_upload_history function."""
@pytest.mark.asyncio
async def test_invalid_package_name(self):
"""Test upload history with invalid package name."""
with pytest.raises(InvalidPackageNameError):
await get_pypi_upload_history("")
@pytest.mark.asyncio
async def test_package_not_found(self):
"""Test upload history for non-existent package."""
with patch.object(PyPIPublishingClient, '_make_request') as mock_request:
mock_response = Mock()
mock_response.status_code = 404
mock_request.return_value = mock_response
with pytest.raises(PackageNotFoundError):
await get_pypi_upload_history("nonexistent-package")
@pytest.mark.asyncio
async def test_successful_history_retrieval(self):
"""Test successful upload history retrieval."""
mock_package_data = {
"info": {
"name": "test-package",
"version": "1.0.0",
"author": "Test Author",
"summary": "A test package",
"license": "MIT",
},
"releases": {
"1.0.0": [
{
"filename": "test_package-1.0.0-py3-none-any.whl",
"upload_time": "2024-01-01T12:00:00",
"upload_time_iso_8601": "2024-01-01T12:00:00Z",
"size": 12345,
"python_version": "py3",
"packagetype": "bdist_wheel",
"md5_digest": "abcd1234",
"digests": {"sha256": "efgh5678"},
"url": "https://pypi.org/...",
"yanked": False,
}
],
"0.9.0": [
{
"filename": "test_package-0.9.0.tar.gz",
"upload_time": "2023-12-01T12:00:00",
"upload_time_iso_8601": "2023-12-01T12:00:00Z",
"size": 9876,
"python_version": "source",
"packagetype": "sdist",
"md5_digest": "wxyz9999",
"digests": {"sha256": "ijkl0000"},
"url": "https://pypi.org/...",
"yanked": True,
"yanked_reason": "Critical bug",
}
],
},
}
with patch.object(PyPIPublishingClient, '_make_request') as mock_request:
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = mock_package_data
mock_request.return_value = mock_response
result = await get_pypi_upload_history("test-package", limit=10)
assert result["package_name"] == "test-package"
assert len(result["upload_history"]) == 2
assert result["statistics"]["total_uploads"] == 2
assert result["statistics"]["total_versions"] == 2
assert result["statistics"]["yanked_uploads"] == 1
assert result["statistics"]["package_types"]["bdist_wheel"] == 1
assert result["statistics"]["package_types"]["sdist"] == 1
# Check that uploads are sorted by time (newest first)
assert result["upload_history"][0]["version"] == "1.0.0"
assert result["upload_history"][1]["version"] == "0.9.0"
@pytest.mark.asyncio
async def test_upload_history_with_limit(self):
"""Test upload history retrieval with limit."""
mock_package_data = {
"info": {"name": "test-package", "version": "3.0.0"},
"releases": {
f"{i}.0.0": [
{
"filename": f"test_package-{i}.0.0.tar.gz",
"upload_time_iso_8601": f"2024-01-{i:02d}T12:00:00Z",
"size": 1000 + i,
"packagetype": "sdist",
"yanked": False,
}
]
for i in range(1, 11) # 10 versions
},
}
with patch.object(PyPIPublishingClient, '_make_request') as mock_request:
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = mock_package_data
mock_request.return_value = mock_response
result = await get_pypi_upload_history("test-package", limit=5)
# Should only return 5 uploads due to limit
assert len(result["upload_history"]) == 5
assert result["limit_applied"] == 5
class TestDeletePyPIRelease:
"""Test cases for delete_pypi_release function."""
@pytest.mark.asyncio
async def test_invalid_package_name(self):
"""Test deletion with invalid package name."""
with pytest.raises(InvalidPackageNameError):
await delete_pypi_release("", "1.0.0")
@pytest.mark.asyncio
async def test_empty_version(self):
"""Test deletion with empty version."""
with pytest.raises(ValueError, match="Version cannot be empty"):
await delete_pypi_release("test-package", "")
@pytest.mark.asyncio
async def test_deletion_not_confirmed(self):
"""Test deletion without confirmation."""
result = await delete_pypi_release(
"test-package",
"1.0.0",
confirm_deletion=False,
dry_run=False
)
assert result["success"] is False
assert "not confirmed" in result["error"]
assert "PRODUCTION PyPI deletion" in result["safety_warnings"]
@pytest.mark.asyncio
async def test_dry_run_successful(self):
"""Test successful dry run deletion."""
mock_release_data = {
"info": {
"name": "test-package",
"version": "1.0.0",
"upload_time": "2024-01-01T12:00:00Z",
"author": "Test Author",
"summary": "Test package",
},
"urls": [
{"filename": "test_package-1.0.0.tar.gz", "packagetype": "sdist"},
{"filename": "test_package-1.0.0-py3-none-any.whl", "packagetype": "bdist_wheel"},
],
}
with patch.object(PyPIPublishingClient, '_make_request') as mock_request:
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = mock_release_data
mock_request.return_value = mock_response
result = await delete_pypi_release(
"test-package",
"1.0.0",
dry_run=True,
test_pypi=True
)
assert result["success"] is True
assert result["dry_run"] is True
assert result["action"] == "dry_run_completed"
assert result["release_info"]["file_count"] == 2
@pytest.mark.asyncio
async def test_package_not_found(self):
"""Test deletion of non-existent package/version."""
with patch.object(PyPIPublishingClient, '_make_request') as mock_request:
mock_response = Mock()
mock_response.status_code = 404
mock_request.return_value = mock_response
with pytest.raises(PackageNotFoundError):
await delete_pypi_release("nonexistent-package", "1.0.0")
@pytest.mark.asyncio
async def test_deletion_permission_denied(self):
"""Test deletion with permission denied."""
mock_release_data = {
"info": {"name": "test-package", "version": "1.0.0"},
"urls": [{"filename": "test_package-1.0.0.tar.gz"}],
}
with patch.object(PyPIPublishingClient, '_make_request') as mock_request:
# First call for verification, second for deletion
verify_response = Mock()
verify_response.status_code = 200
verify_response.json.return_value = mock_release_data
delete_response = Mock()
delete_response.status_code = 403
mock_request.side_effect = [verify_response, delete_response]
result = await delete_pypi_release(
"test-package",
"1.0.0",
confirm_deletion=True,
dry_run=False,
test_pypi=True
)
assert result["success"] is False
assert result["action"] == "permission_denied"
@pytest.mark.asyncio
async def test_successful_deletion(self):
"""Test successful deletion."""
mock_release_data = {
"info": {"name": "test-package", "version": "1.0.0"},
"urls": [{"filename": "test_package-1.0.0.tar.gz"}],
}
with patch.object(PyPIPublishingClient, '_make_request') as mock_request:
# First call for verification, second for deletion
verify_response = Mock()
verify_response.status_code = 200
verify_response.json.return_value = mock_release_data
delete_response = Mock()
delete_response.status_code = 204
mock_request.side_effect = [verify_response, delete_response]
result = await delete_pypi_release(
"test-package",
"1.0.0",
confirm_deletion=True,
dry_run=False,
test_pypi=True
)
assert result["success"] is True
assert result["action"] == "deleted"
class TestManagePyPIMaintainers:
"""Test cases for manage_pypi_maintainers function."""
@pytest.mark.asyncio
async def test_invalid_package_name(self):
"""Test maintainer management with invalid package name."""
with pytest.raises(InvalidPackageNameError):
await manage_pypi_maintainers("", "list")
@pytest.mark.asyncio
async def test_invalid_action(self):
"""Test maintainer management with invalid action."""
with pytest.raises(ValueError, match="Invalid action"):
await manage_pypi_maintainers("test-package", "invalid")
@pytest.mark.asyncio
async def test_missing_username_for_add(self):
"""Test add action without username."""
with pytest.raises(ValueError, match="Username required"):
await manage_pypi_maintainers("test-package", "add")
@pytest.mark.asyncio
async def test_list_maintainers_successful(self):
"""Test successful maintainer listing."""
mock_package_data = {
"info": {
"name": "test-package",
"version": "1.0.0",
"author": "John Doe",
"author_email": "john@example.com",
"maintainer": "Jane Smith",
"maintainer_email": "jane@example.com",
"summary": "Test package",
"license": "MIT",
},
}
with patch.object(PyPIPublishingClient, '_make_request') as mock_request:
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = mock_package_data
mock_request.return_value = mock_response
result = await manage_pypi_maintainers("test-package", "list")
assert result["success"] is True
assert result["action"] == "list"
assert result["maintainer_count"] == 2
assert len(result["current_maintainers"]) == 2
# Check author information
author_info = next(m for m in result["current_maintainers"] if m["type"] == "author")
assert author_info["name"] == "John Doe"
assert author_info["email"] == "john@example.com"
# Check maintainer information
maintainer_info = next(m for m in result["current_maintainers"] if m["type"] == "maintainer")
assert maintainer_info["name"] == "Jane Smith"
assert maintainer_info["email"] == "jane@example.com"
@pytest.mark.asyncio
async def test_add_maintainer_not_supported(self):
"""Test add maintainer operation (not supported via API)."""
mock_package_data = {
"info": {"name": "test-package", "author": "John Doe"},
}
with patch.object(PyPIPublishingClient, '_make_request') as mock_request:
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = mock_package_data
mock_request.return_value = mock_response
result = await manage_pypi_maintainers(
"test-package",
"add",
username="newuser"
)
assert result["success"] is False
assert "not supported via API" in result["error"]
assert "alternative_method" in result
assert "web interface" in result["alternative_method"]["description"]
@pytest.mark.asyncio
async def test_package_not_found(self):
"""Test maintainer management for non-existent package."""
with patch.object(PyPIPublishingClient, '_make_request') as mock_request:
mock_response = Mock()
mock_response.status_code = 404
mock_request.return_value = mock_response
with pytest.raises(PackageNotFoundError):
await manage_pypi_maintainers("nonexistent-package", "list")
class TestGetPyPIAccountInfo:
"""Test cases for get_pypi_account_info function."""
@pytest.mark.asyncio
async def test_invalid_credentials(self):
"""Test account info with invalid credentials."""
with patch('pypi_query_mcp.tools.publishing.check_pypi_credentials') as mock_cred:
mock_cred.return_value = {"valid": False}
with pytest.raises(PyPIAuthenticationError):
await get_pypi_account_info(api_token="invalid-token")
@pytest.mark.asyncio
async def test_successful_account_info(self):
"""Test successful account info retrieval."""
with patch('pypi_query_mcp.tools.publishing.check_pypi_credentials') as mock_cred:
mock_cred.return_value = {"valid": True, "repository": "PyPI"}
result = await get_pypi_account_info(api_token="valid-token")
assert result["repository"] == "PyPI"
assert result["credentials"]["valid"] is True
assert "account_info" in result
assert "recommendations" in result
assert "useful_links" in result
# Check account info structure
account_info = result["account_info"]
assert account_info["api_token_valid"] is True
assert "account_limitations" in account_info
assert "features" in account_info
assert "user_projects" in account_info
# Check recommendations
assert len(result["recommendations"]) > 0
assert any("two-factor" in rec for rec in result["recommendations"])
# Check useful links
links = result["useful_links"]
assert "account_settings" in links
assert "api_tokens" in links
assert "projects" in links
@pytest.mark.asyncio
async def test_test_pypi_account_info(self):
"""Test account info for TestPyPI."""
with patch('pypi_query_mcp.tools.publishing.check_pypi_credentials') as mock_cred:
mock_cred.return_value = {"valid": True, "repository": "TestPyPI"}
result = await get_pypi_account_info(test_pypi=True)
assert result["repository"] == "TestPyPI"
assert result["test_pypi"] is True
assert "test.pypi.org" in result["useful_links"]["account_settings"]
class TestIntegration:
"""Integration tests for publishing tools."""
@pytest.mark.asyncio
async def test_end_to_end_workflow_simulation(self):
"""Test simulated end-to-end workflow."""
# This test simulates a complete workflow without making real API calls
# 1. Check credentials
with patch('pypi_query_mcp.tools.publishing.check_pypi_credentials') as mock_cred:
mock_cred.return_value = {"valid": True}
cred_result = await check_pypi_credentials(api_token="pypi-test-token")
assert cred_result["valid"] is True
# 2. Get account info
with patch('pypi_query_mcp.tools.publishing.check_pypi_credentials') as mock_cred:
mock_cred.return_value = {"valid": True}
account_result = await get_pypi_account_info(api_token="pypi-test-token")
assert "account_info" in account_result
# 3. Check upload history
mock_package_data = {
"info": {"name": "test-package"},
"releases": {"1.0.0": [{"filename": "test-1.0.0.tar.gz"}]},
}
with patch.object(PyPIPublishingClient, '_make_request') as mock_request:
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = mock_package_data
mock_request.return_value = mock_response
history_result = await get_pypi_upload_history("test-package")
assert len(history_result["upload_history"]) == 1
# 4. Test dry run deletion
with patch.object(PyPIPublishingClient, '_make_request') as mock_request:
mock_response = Mock()
mock_response.status_code = 200
mock_response.json.return_value = {
"info": {"name": "test-package", "version": "1.0.0"},
"urls": [{"filename": "test-1.0.0.tar.gz"}],
}
mock_request.return_value = mock_response
delete_result = await delete_pypi_release(
"test-package", "1.0.0", dry_run=True
)
assert delete_result["dry_run"] is True
assert delete_result["success"] is True
if __name__ == "__main__":
pytest.main([__file__])