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.
848 lines
33 KiB
Python
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__]) |