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.
This commit is contained in:
Ryan Malloy 2025-08-16 08:52:03 -06:00
parent e205176ace
commit 9f3fd459b3
5 changed files with 2170 additions and 0 deletions

View File

@ -62,3 +62,24 @@ class SearchError(PyPIError):
def __init__(self, message: str, query: str | None = None):
super().__init__(message)
self.query = query
class PyPIAuthenticationError(PyPIError):
"""Raised when PyPI authentication fails."""
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message, status_code)
class PyPIUploadError(PyPIError):
"""Raised when PyPI upload operations fail."""
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message, status_code)
class PyPIPermissionError(PyPIError):
"""Raised when PyPI permission operations fail."""
def __init__(self, message: str, status_code: int | None = None):
super().__init__(message, status_code)

View File

@ -24,20 +24,26 @@ from .prompts import (
track_package_updates,
)
from .tools import (
check_pypi_credentials,
check_python_compatibility,
delete_pypi_release,
download_package_with_dependencies,
find_alternatives,
get_compatible_python_versions,
get_package_download_stats,
get_package_download_trends,
get_pypi_account_info,
get_pypi_upload_history,
get_top_packages_by_downloads,
get_trending_packages,
manage_pypi_maintainers,
query_package_dependencies,
query_package_info,
query_package_versions,
resolve_package_dependencies,
search_by_category,
search_packages,
upload_package_to_pypi,
)
# Configure logging
@ -806,6 +812,285 @@ async def get_trending_pypi_packages(
}
# PyPI Publishing and Account Management Tools
@mcp.tool()
async def upload_package_to_pypi_tool(
distribution_paths: list[str],
api_token: str | None = None,
test_pypi: bool = False,
skip_existing: bool = True,
verify_uploads: bool = True,
) -> dict[str, Any]:
"""Upload package distributions to PyPI or TestPyPI.
This tool uploads Python package distributions (.whl, .tar.gz files) to PyPI,
providing comprehensive upload management with safety checks and verification.
Args:
distribution_paths: List of paths to distribution files (.whl, .tar.gz)
api_token: PyPI API token (or use PYPI_API_TOKEN env var)
test_pypi: Whether to upload to TestPyPI instead of production PyPI
skip_existing: Skip files that already exist on PyPI
verify_uploads: Verify uploads after completion
Returns:
Dictionary containing upload results and metadata
Raises:
PyPIAuthenticationError: If authentication fails
PyPIUploadError: If upload operations fail
NetworkError: For network-related errors
"""
try:
logger.info(f"MCP tool: Uploading {len(distribution_paths)} distributions to {'TestPyPI' if test_pypi else 'PyPI'}")
result = await upload_package_to_pypi(
distribution_paths=distribution_paths,
api_token=api_token,
test_pypi=test_pypi,
skip_existing=skip_existing,
verify_uploads=verify_uploads,
)
logger.info(f"Upload completed: {result.get('summary', {})}")
return result
except Exception as e:
logger.error(f"Error uploading packages: {e}")
return {
"error": str(e),
"error_type": type(e).__name__,
"distribution_paths": distribution_paths,
"test_pypi": test_pypi,
}
@mcp.tool()
async def check_pypi_credentials_tool(
api_token: str | None = None,
test_pypi: bool = False,
) -> dict[str, Any]:
"""Validate PyPI API token and credentials.
This tool checks if your PyPI API token is valid and provides information
about your account permissions and capabilities.
Args:
api_token: PyPI API token (or use PYPI_API_TOKEN env var)
test_pypi: Whether to check against TestPyPI instead of production PyPI
Returns:
Dictionary containing credential validation results
Raises:
PyPIAuthenticationError: If credential validation fails
NetworkError: For network-related errors
"""
try:
logger.info(f"MCP tool: Checking {'TestPyPI' if test_pypi else 'PyPI'} credentials")
result = await check_pypi_credentials(api_token=api_token, test_pypi=test_pypi)
logger.info(f"Credential check completed: valid={result.get('valid', False)}")
return result
except Exception as e:
logger.error(f"Error checking credentials: {e}")
return {
"error": str(e),
"error_type": type(e).__name__,
"test_pypi": test_pypi,
}
@mcp.tool()
async def get_pypi_upload_history_tool(
package_name: str,
api_token: str | None = None,
test_pypi: bool = False,
limit: int = 50,
) -> dict[str, Any]:
"""Get upload history for a PyPI package.
This tool retrieves the upload history for a package, showing all versions,
files, and upload metadata with statistics and analysis.
Args:
package_name: Name of the package to get upload history for
api_token: PyPI API token (or use PYPI_API_TOKEN env var)
test_pypi: Whether to check TestPyPI instead of production PyPI
limit: Maximum number of uploads to return
Returns:
Dictionary containing upload history and metadata
Raises:
InvalidPackageNameError: If package name is invalid
PackageNotFoundError: If package is not found
NetworkError: For network-related errors
"""
try:
logger.info(f"MCP tool: Getting upload history for {package_name}")
result = await get_pypi_upload_history(
package_name=package_name,
api_token=api_token,
test_pypi=test_pypi,
limit=limit,
)
upload_count = result.get('statistics', {}).get('total_uploads', 0)
logger.info(f"Retrieved {upload_count} upload records for {package_name}")
return result
except Exception as e:
logger.error(f"Error getting upload history for {package_name}: {e}")
return {
"error": str(e),
"error_type": type(e).__name__,
"package_name": package_name,
"test_pypi": test_pypi,
}
@mcp.tool()
async def delete_pypi_release_tool(
package_name: str,
version: str,
api_token: str | None = None,
test_pypi: bool = False,
confirm_deletion: bool = False,
dry_run: bool = True,
) -> dict[str, Any]:
"""Delete a specific release from PyPI (with safety checks).
This tool provides safe deletion of PyPI releases with multiple safety checks,
dry-run capability, and comprehensive validation. Note that PyPI deletion is
very restricted and typically only available to package owners within a limited
time window after upload.
Args:
package_name: Name of the package
version: Version to delete
api_token: PyPI API token (or use PYPI_API_TOKEN env var)
test_pypi: Whether to use TestPyPI instead of production PyPI
confirm_deletion: Explicit confirmation required for actual deletion
dry_run: If True, only simulate the deletion without actually performing it
Returns:
Dictionary containing deletion results and safety information
Raises:
InvalidPackageNameError: If package name is invalid
PackageNotFoundError: If package/version is not found
PyPIPermissionError: If deletion is not permitted
NetworkError: For network-related errors
"""
try:
logger.info(f"MCP tool: {'DRY RUN: ' if dry_run else ''}Deleting {package_name}=={version}")
result = await delete_pypi_release(
package_name=package_name,
version=version,
api_token=api_token,
test_pypi=test_pypi,
confirm_deletion=confirm_deletion,
dry_run=dry_run,
)
action = result.get('action', 'unknown')
logger.info(f"Deletion operation completed: {action}")
return result
except Exception as e:
logger.error(f"Error deleting release {package_name}=={version}: {e}")
return {
"error": str(e),
"error_type": type(e).__name__,
"package_name": package_name,
"version": version,
"test_pypi": test_pypi,
}
@mcp.tool()
async def manage_pypi_maintainers_tool(
package_name: str,
action: str,
username: str | None = None,
api_token: str | None = None,
test_pypi: bool = False,
) -> dict[str, Any]:
"""Manage package maintainers (add/remove/list).
This tool helps manage package maintainers and collaborators. Note that maintainer
management typically requires package owner permissions and may need to be done
through the PyPI web interface.
Args:
package_name: Name of the package
action: Action to perform ('list', 'add', 'remove')
username: Username to add/remove (required for add/remove actions)
api_token: PyPI API token (or use PYPI_API_TOKEN env var)
test_pypi: Whether to use TestPyPI instead of production PyPI
Returns:
Dictionary containing maintainer management results
Raises:
InvalidPackageNameError: If package name is invalid
PackageNotFoundError: If package is not found
PyPIPermissionError: If action is not permitted
NetworkError: For network-related errors
"""
try:
logger.info(f"MCP tool: Managing maintainers for {package_name}: {action}")
result = await manage_pypi_maintainers(
package_name=package_name,
action=action,
username=username,
api_token=api_token,
test_pypi=test_pypi,
)
maintainer_count = result.get('maintainer_count', 0)
logger.info(f"Maintainer management completed: {maintainer_count} maintainers")
return result
except Exception as e:
logger.error(f"Error managing maintainers for {package_name}: {e}")
return {
"error": str(e),
"error_type": type(e).__name__,
"package_name": package_name,
"action": action,
"test_pypi": test_pypi,
}
@mcp.tool()
async def get_pypi_account_info_tool(
api_token: str | None = None,
test_pypi: bool = False,
) -> dict[str, Any]:
"""Get PyPI account information, quotas, and limits.
This tool retrieves information about your PyPI account including permissions,
limitations, quotas, and provides recommendations for account security and usage.
Args:
api_token: PyPI API token (or use PYPI_API_TOKEN env var)
test_pypi: Whether to use TestPyPI instead of production PyPI
Returns:
Dictionary containing account information and limitations
Raises:
PyPIAuthenticationError: If authentication fails
NetworkError: For network-related errors
"""
try:
logger.info(f"MCP tool: Getting account information for {'TestPyPI' if test_pypi else 'PyPI'}")
result = await get_pypi_account_info(api_token=api_token, test_pypi=test_pypi)
logger.info("Account information retrieved successfully")
return result
except Exception as e:
logger.error(f"Error getting account information: {e}")
return {
"error": str(e),
"error_type": type(e).__name__,
"test_pypi": test_pypi,
}
# Register prompt templates following standard MCP workflow:
# 1. User calls tool → MCP client sends request
# 2. Tool function executes → Collects necessary data and parameters

View File

@ -21,6 +21,14 @@ from .package_query import (
query_package_info,
query_package_versions,
)
from .publishing import (
check_pypi_credentials,
delete_pypi_release,
get_pypi_account_info,
get_pypi_upload_history,
manage_pypi_maintainers,
upload_package_to_pypi,
)
from .search import (
find_alternatives,
get_trending_packages,
@ -44,4 +52,10 @@ __all__ = [
"search_by_category",
"find_alternatives",
"get_trending_packages",
"upload_package_to_pypi",
"check_pypi_credentials",
"get_pypi_upload_history",
"delete_pypi_release",
"manage_pypi_maintainers",
"get_pypi_account_info",
]

File diff suppressed because it is too large Load Diff

848
tests/test_publishing.py Normal file
View File

@ -0,0 +1,848 @@
"""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__])