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:
parent
e205176ace
commit
9f3fd459b3
@ -62,3 +62,24 @@ class SearchError(PyPIError):
|
|||||||
def __init__(self, message: str, query: str | None = None):
|
def __init__(self, message: str, query: str | None = None):
|
||||||
super().__init__(message)
|
super().__init__(message)
|
||||||
self.query = query
|
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)
|
||||||
|
@ -24,20 +24,26 @@ from .prompts import (
|
|||||||
track_package_updates,
|
track_package_updates,
|
||||||
)
|
)
|
||||||
from .tools import (
|
from .tools import (
|
||||||
|
check_pypi_credentials,
|
||||||
check_python_compatibility,
|
check_python_compatibility,
|
||||||
|
delete_pypi_release,
|
||||||
download_package_with_dependencies,
|
download_package_with_dependencies,
|
||||||
find_alternatives,
|
find_alternatives,
|
||||||
get_compatible_python_versions,
|
get_compatible_python_versions,
|
||||||
get_package_download_stats,
|
get_package_download_stats,
|
||||||
get_package_download_trends,
|
get_package_download_trends,
|
||||||
|
get_pypi_account_info,
|
||||||
|
get_pypi_upload_history,
|
||||||
get_top_packages_by_downloads,
|
get_top_packages_by_downloads,
|
||||||
get_trending_packages,
|
get_trending_packages,
|
||||||
|
manage_pypi_maintainers,
|
||||||
query_package_dependencies,
|
query_package_dependencies,
|
||||||
query_package_info,
|
query_package_info,
|
||||||
query_package_versions,
|
query_package_versions,
|
||||||
resolve_package_dependencies,
|
resolve_package_dependencies,
|
||||||
search_by_category,
|
search_by_category,
|
||||||
search_packages,
|
search_packages,
|
||||||
|
upload_package_to_pypi,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Configure logging
|
# 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:
|
# Register prompt templates following standard MCP workflow:
|
||||||
# 1. User calls tool → MCP client sends request
|
# 1. User calls tool → MCP client sends request
|
||||||
# 2. Tool function executes → Collects necessary data and parameters
|
# 2. Tool function executes → Collects necessary data and parameters
|
||||||
|
@ -21,6 +21,14 @@ from .package_query import (
|
|||||||
query_package_info,
|
query_package_info,
|
||||||
query_package_versions,
|
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 (
|
from .search import (
|
||||||
find_alternatives,
|
find_alternatives,
|
||||||
get_trending_packages,
|
get_trending_packages,
|
||||||
@ -44,4 +52,10 @@ __all__ = [
|
|||||||
"search_by_category",
|
"search_by_category",
|
||||||
"find_alternatives",
|
"find_alternatives",
|
||||||
"get_trending_packages",
|
"get_trending_packages",
|
||||||
|
"upload_package_to_pypi",
|
||||||
|
"check_pypi_credentials",
|
||||||
|
"get_pypi_upload_history",
|
||||||
|
"delete_pypi_release",
|
||||||
|
"manage_pypi_maintainers",
|
||||||
|
"get_pypi_account_info",
|
||||||
]
|
]
|
||||||
|
1002
pypi_query_mcp/tools/publishing.py
Normal file
1002
pypi_query_mcp/tools/publishing.py
Normal file
File diff suppressed because it is too large
Load Diff
848
tests/test_publishing.py
Normal file
848
tests/test_publishing.py
Normal 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__])
|
Loading…
x
Reference in New Issue
Block a user