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