feat: implement comprehensive configuration management system with multi-mirror support
- Add ServerSettings class with pydantic-settings for type-safe configuration - Support multiple PyPI mirror sources with priority-based fallback mechanism - Implement RepositoryConfig and RepositoryManager for multi-repository support - Add environment variable support for all configuration options - Include private repository authentication configuration - Add advanced dependency analysis settings (max depth, concurrency, security) - Provide secure credential management with sensitive data masking - Update documentation and configuration examples - Add comprehensive test suite with 23 test cases covering all features - Include demo script showcasing multi-mirror configuration capabilities Configuration features: - Primary, additional, and fallback index URLs - Automatic duplicate URL removal with priority preservation - Runtime configuration reloading - Integration with repository manager for seamless multi-source queries Signed-off-by: longhao <hal.long@outlook.com>
This commit is contained in:
parent
f27493d8d2
commit
a0c507c3ff
20 changed files with 1276 additions and 165 deletions
|
|
@ -4,5 +4,32 @@ This package handles configuration loading, validation, and management
|
|||
for the MCP server, including private registry settings.
|
||||
"""
|
||||
|
||||
# Configuration exports will be added as modules are implemented
|
||||
__all__ = []
|
||||
from .repository import (
|
||||
AuthType,
|
||||
RepositoryConfig,
|
||||
RepositoryManager,
|
||||
RepositoryType,
|
||||
get_repository_manager,
|
||||
reload_repository_manager,
|
||||
)
|
||||
from .settings import (
|
||||
ServerSettings,
|
||||
get_settings,
|
||||
reload_settings,
|
||||
update_settings,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
# Settings
|
||||
"ServerSettings",
|
||||
"get_settings",
|
||||
"reload_settings",
|
||||
"update_settings",
|
||||
# Repository
|
||||
"RepositoryConfig",
|
||||
"RepositoryManager",
|
||||
"RepositoryType",
|
||||
"AuthType",
|
||||
"get_repository_manager",
|
||||
"reload_repository_manager",
|
||||
]
|
||||
|
|
|
|||
252
pypi_query_mcp/config/repository.py
Normal file
252
pypi_query_mcp/config/repository.py
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
"""Repository configuration for PyPI Query MCP Server."""
|
||||
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
|
||||
class RepositoryType(str, Enum):
|
||||
"""Repository type enumeration."""
|
||||
|
||||
PUBLIC = "public"
|
||||
PRIVATE = "private"
|
||||
|
||||
|
||||
class AuthType(str, Enum):
|
||||
"""Authentication type enumeration."""
|
||||
|
||||
NONE = "none"
|
||||
BASIC = "basic"
|
||||
TOKEN = "token"
|
||||
|
||||
|
||||
class RepositoryConfig(BaseModel):
|
||||
"""Configuration for a PyPI repository."""
|
||||
|
||||
name: str = Field(description="Repository name")
|
||||
url: str = Field(description="Repository URL")
|
||||
type: RepositoryType = Field(description="Repository type")
|
||||
priority: int = Field(
|
||||
default=100, description="Repository priority (lower = higher priority)"
|
||||
)
|
||||
|
||||
# Authentication settings
|
||||
auth_type: AuthType = Field(
|
||||
default=AuthType.NONE, description="Authentication type"
|
||||
)
|
||||
username: str | None = Field(
|
||||
default=None, description="Username for authentication"
|
||||
)
|
||||
password: str | None = Field(
|
||||
default=None, description="Password for authentication"
|
||||
)
|
||||
token: str | None = Field(default=None, description="Token for authentication")
|
||||
|
||||
# Connection settings
|
||||
timeout: float = Field(default=30.0, description="Request timeout in seconds")
|
||||
max_retries: int = Field(default=3, description="Maximum retry attempts")
|
||||
verify_ssl: bool = Field(default=True, description="Verify SSL certificates")
|
||||
|
||||
# Feature flags
|
||||
enabled: bool = Field(default=True, description="Whether repository is enabled")
|
||||
use_cache: bool = Field(default=True, description="Whether to cache responses")
|
||||
|
||||
@field_validator("priority")
|
||||
@classmethod
|
||||
def validate_priority(cls, v: int) -> int:
|
||||
"""Validate repository priority."""
|
||||
if v < 1 or v > 1000:
|
||||
raise ValueError("Priority must be between 1 and 1000")
|
||||
return v
|
||||
|
||||
@field_validator("timeout")
|
||||
@classmethod
|
||||
def validate_timeout(cls, v: float) -> float:
|
||||
"""Validate timeout."""
|
||||
if v <= 0:
|
||||
raise ValueError("Timeout must be positive")
|
||||
return v
|
||||
|
||||
@field_validator("max_retries")
|
||||
@classmethod
|
||||
def validate_max_retries(cls, v: int) -> int:
|
||||
"""Validate max retries."""
|
||||
if v < 0 or v > 10:
|
||||
raise ValueError("Max retries must be between 0 and 10")
|
||||
return v
|
||||
|
||||
def requires_auth(self) -> bool:
|
||||
"""Check if repository requires authentication."""
|
||||
return self.auth_type != AuthType.NONE
|
||||
|
||||
def has_credentials(self) -> bool:
|
||||
"""Check if repository has valid credentials."""
|
||||
if self.auth_type == AuthType.BASIC:
|
||||
return bool(self.username and self.password)
|
||||
elif self.auth_type == AuthType.TOKEN:
|
||||
return bool(self.token)
|
||||
return True # No auth required
|
||||
|
||||
def get_safe_dict(self) -> dict[str, Any]:
|
||||
"""Get repository config as dictionary with sensitive data masked."""
|
||||
data = self.model_dump()
|
||||
# Mask sensitive information
|
||||
if data.get("password"):
|
||||
data["password"] = "***"
|
||||
if data.get("token"):
|
||||
data["token"] = "***"
|
||||
return data
|
||||
|
||||
|
||||
class RepositoryManager:
|
||||
"""Manager for repository configurations."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize repository manager."""
|
||||
self._repositories: dict[str, RepositoryConfig] = {}
|
||||
self._load_default_repositories()
|
||||
|
||||
def _load_default_repositories(self) -> None:
|
||||
"""Load default repository configurations."""
|
||||
# Add default public PyPI repository
|
||||
public_repo = RepositoryConfig(
|
||||
name="pypi",
|
||||
url="https://pypi.org/pypi",
|
||||
type=RepositoryType.PUBLIC,
|
||||
priority=100,
|
||||
auth_type=AuthType.NONE,
|
||||
)
|
||||
self._repositories["pypi"] = public_repo
|
||||
|
||||
def load_repositories_from_settings(self, settings) -> None:
|
||||
"""Load repositories from settings configuration."""
|
||||
# Clear existing repositories except default PyPI
|
||||
repos_to_keep = {
|
||||
name: repo
|
||||
for name, repo in self._repositories.items()
|
||||
if repo.type == RepositoryType.PUBLIC and name == "pypi"
|
||||
}
|
||||
self._repositories = repos_to_keep
|
||||
|
||||
# Add repositories from index URLs
|
||||
all_urls = settings.get_all_index_urls()
|
||||
primary_urls = settings.get_primary_index_urls()
|
||||
fallback_urls = settings.get_fallback_index_urls()
|
||||
|
||||
# Update primary PyPI URL if different from default
|
||||
if all_urls and all_urls[0] != "https://pypi.org/pypi":
|
||||
self._repositories["pypi"].url = all_urls[0]
|
||||
|
||||
# Add additional primary index URLs
|
||||
for i, url in enumerate(
|
||||
primary_urls[1:], 1
|
||||
): # Skip first URL (already set as primary)
|
||||
repo_name = f"index_{i}"
|
||||
repo = RepositoryConfig(
|
||||
name=repo_name,
|
||||
url=url,
|
||||
type=RepositoryType.PUBLIC,
|
||||
priority=100 + i, # Slightly lower priority than primary
|
||||
auth_type=AuthType.NONE,
|
||||
)
|
||||
self._repositories[repo_name] = repo
|
||||
|
||||
# Add fallback index URLs
|
||||
for i, url in enumerate(fallback_urls):
|
||||
repo_name = f"fallback_{i}"
|
||||
repo = RepositoryConfig(
|
||||
name=repo_name,
|
||||
url=url,
|
||||
type=RepositoryType.PUBLIC,
|
||||
priority=200 + i, # Lower priority for fallbacks
|
||||
auth_type=AuthType.NONE,
|
||||
)
|
||||
self._repositories[repo_name] = repo
|
||||
|
||||
# Add private repository if configured
|
||||
if settings.has_private_repo():
|
||||
self.add_private_repository_from_settings(
|
||||
settings.private_pypi_url,
|
||||
settings.private_pypi_username,
|
||||
settings.private_pypi_password,
|
||||
)
|
||||
|
||||
def add_repository(self, repo: RepositoryConfig) -> None:
|
||||
"""Add a repository configuration."""
|
||||
if not repo.has_credentials() and repo.requires_auth():
|
||||
raise ValueError(
|
||||
f"Repository {repo.name} requires authentication but has no credentials"
|
||||
)
|
||||
self._repositories[repo.name] = repo
|
||||
|
||||
def remove_repository(self, name: str) -> None:
|
||||
"""Remove a repository configuration."""
|
||||
if name == "pypi":
|
||||
raise ValueError("Cannot remove default PyPI repository")
|
||||
self._repositories.pop(name, None)
|
||||
|
||||
def get_repository(self, name: str) -> RepositoryConfig | None:
|
||||
"""Get repository configuration by name."""
|
||||
return self._repositories.get(name)
|
||||
|
||||
def list_repositories(self) -> list[RepositoryConfig]:
|
||||
"""List all repository configurations."""
|
||||
return list(self._repositories.values())
|
||||
|
||||
def get_enabled_repositories(self) -> list[RepositoryConfig]:
|
||||
"""Get all enabled repositories sorted by priority."""
|
||||
enabled = [repo for repo in self._repositories.values() if repo.enabled]
|
||||
return sorted(enabled, key=lambda x: x.priority)
|
||||
|
||||
def get_private_repositories(self) -> list[RepositoryConfig]:
|
||||
"""Get all private repositories."""
|
||||
return [
|
||||
repo
|
||||
for repo in self._repositories.values()
|
||||
if repo.type == RepositoryType.PRIVATE and repo.enabled
|
||||
]
|
||||
|
||||
def has_private_repositories(self) -> bool:
|
||||
"""Check if any private repositories are configured."""
|
||||
return len(self.get_private_repositories()) > 0
|
||||
|
||||
def add_private_repository_from_settings(
|
||||
self, url: str, username: str | None = None, password: str | None = None
|
||||
) -> None:
|
||||
"""Add private repository from settings."""
|
||||
if not url:
|
||||
return
|
||||
|
||||
auth_type = AuthType.BASIC if username and password else AuthType.NONE
|
||||
|
||||
private_repo = RepositoryConfig(
|
||||
name="private",
|
||||
url=url,
|
||||
type=RepositoryType.PRIVATE,
|
||||
priority=1, # Higher priority than public
|
||||
auth_type=auth_type,
|
||||
username=username,
|
||||
password=password,
|
||||
)
|
||||
|
||||
self.add_repository(private_repo)
|
||||
|
||||
|
||||
# Global repository manager instance
|
||||
_repository_manager: RepositoryManager | None = None
|
||||
|
||||
|
||||
def get_repository_manager() -> RepositoryManager:
|
||||
"""Get global repository manager instance."""
|
||||
global _repository_manager
|
||||
if _repository_manager is None:
|
||||
_repository_manager = RepositoryManager()
|
||||
return _repository_manager
|
||||
|
||||
|
||||
def reload_repository_manager() -> RepositoryManager:
|
||||
"""Reload repository manager."""
|
||||
global _repository_manager
|
||||
_repository_manager = RepositoryManager()
|
||||
return _repository_manager
|
||||
198
pypi_query_mcp/config/settings.py
Normal file
198
pypi_query_mcp/config/settings.py
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
"""Configuration settings for PyPI Query MCP Server."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import Field, field_validator
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class ServerSettings(BaseSettings):
|
||||
"""Server configuration settings."""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_prefix="PYPI_",
|
||||
env_file=".env",
|
||||
env_file_encoding="utf-8",
|
||||
case_sensitive=False,
|
||||
extra="ignore",
|
||||
)
|
||||
|
||||
# Basic server settings
|
||||
log_level: str = Field(default="INFO", description="Logging level")
|
||||
cache_ttl: int = Field(default=3600, description="Cache time-to-live in seconds")
|
||||
request_timeout: float = Field(
|
||||
default=30.0, description="HTTP request timeout in seconds"
|
||||
)
|
||||
max_retries: int = Field(default=3, description="Maximum number of retry attempts")
|
||||
retry_delay: float = Field(
|
||||
default=1.0, description="Delay between retries in seconds"
|
||||
)
|
||||
|
||||
# PyPI settings
|
||||
index_url: str = Field(
|
||||
default="https://pypi.org/pypi", description="Primary PyPI index URL"
|
||||
)
|
||||
index_urls: str | None = Field(
|
||||
default=None, description="Additional PyPI index URLs (comma-separated)"
|
||||
)
|
||||
extra_index_urls: str | None = Field(
|
||||
default=None, description="Extra PyPI index URLs for fallback (comma-separated)"
|
||||
)
|
||||
|
||||
# Private repository settings
|
||||
private_pypi_url: str | None = Field(
|
||||
default=None, description="Private PyPI repository URL"
|
||||
)
|
||||
private_pypi_username: str | None = Field(
|
||||
default=None, description="Private PyPI username"
|
||||
)
|
||||
private_pypi_password: str | None = Field(
|
||||
default=None, description="Private PyPI password"
|
||||
)
|
||||
|
||||
# Advanced dependency analysis settings
|
||||
dependency_max_depth: int = Field(
|
||||
default=5, description="Maximum depth for recursive dependency analysis"
|
||||
)
|
||||
dependency_max_concurrent: int = Field(
|
||||
default=10, description="Maximum concurrent dependency queries"
|
||||
)
|
||||
enable_security_analysis: bool = Field(
|
||||
default=False, description="Enable security vulnerability analysis"
|
||||
)
|
||||
|
||||
@field_validator("log_level")
|
||||
@classmethod
|
||||
def validate_log_level(cls, v: str) -> str:
|
||||
"""Validate log level."""
|
||||
valid_levels = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}
|
||||
if v.upper() not in valid_levels:
|
||||
raise ValueError(f"Invalid log level: {v}. Must be one of {valid_levels}")
|
||||
return v.upper()
|
||||
|
||||
@field_validator("cache_ttl")
|
||||
@classmethod
|
||||
def validate_cache_ttl(cls, v: int) -> int:
|
||||
"""Validate cache TTL."""
|
||||
if v < 0:
|
||||
raise ValueError("Cache TTL must be non-negative")
|
||||
return v
|
||||
|
||||
@field_validator("dependency_max_depth")
|
||||
@classmethod
|
||||
def validate_dependency_max_depth(cls, v: int) -> int:
|
||||
"""Validate dependency analysis max depth."""
|
||||
if v < 1 or v > 10:
|
||||
raise ValueError("Dependency max depth must be between 1 and 10")
|
||||
return v
|
||||
|
||||
@field_validator("dependency_max_concurrent")
|
||||
@classmethod
|
||||
def validate_dependency_max_concurrent(cls, v: int) -> int:
|
||||
"""Validate max concurrent dependency queries."""
|
||||
if v < 1 or v > 50:
|
||||
raise ValueError("Max concurrent queries must be between 1 and 50")
|
||||
return v
|
||||
|
||||
def has_private_repo(self) -> bool:
|
||||
"""Check if private repository is configured."""
|
||||
return bool(self.private_pypi_url)
|
||||
|
||||
def has_private_auth(self) -> bool:
|
||||
"""Check if private repository authentication is configured."""
|
||||
return bool(
|
||||
self.private_pypi_url
|
||||
and self.private_pypi_username
|
||||
and self.private_pypi_password
|
||||
)
|
||||
|
||||
def get_all_index_urls(self) -> list[str]:
|
||||
"""Get all configured index URLs in priority order."""
|
||||
urls = [self.index_url]
|
||||
|
||||
# Add additional index URLs
|
||||
if self.index_urls:
|
||||
additional_urls = [
|
||||
url.strip() for url in self.index_urls.split(",") if url.strip()
|
||||
]
|
||||
urls.extend(additional_urls)
|
||||
|
||||
# Add extra index URLs (lower priority)
|
||||
if self.extra_index_urls:
|
||||
extra_urls = [
|
||||
url.strip() for url in self.extra_index_urls.split(",") if url.strip()
|
||||
]
|
||||
urls.extend(extra_urls)
|
||||
|
||||
# Remove duplicates while preserving order
|
||||
seen = set()
|
||||
unique_urls = []
|
||||
for url in urls:
|
||||
if url not in seen:
|
||||
seen.add(url)
|
||||
unique_urls.append(url)
|
||||
|
||||
return unique_urls
|
||||
|
||||
def get_primary_index_urls(self) -> list[str]:
|
||||
"""Get primary index URLs (excluding extra fallback URLs)."""
|
||||
urls = [self.index_url]
|
||||
|
||||
if self.index_urls:
|
||||
additional_urls = [
|
||||
url.strip() for url in self.index_urls.split(",") if url.strip()
|
||||
]
|
||||
urls.extend(additional_urls)
|
||||
|
||||
# Remove duplicates while preserving order
|
||||
seen = set()
|
||||
unique_urls = []
|
||||
for url in urls:
|
||||
if url not in seen:
|
||||
seen.add(url)
|
||||
unique_urls.append(url)
|
||||
|
||||
return unique_urls
|
||||
|
||||
def get_fallback_index_urls(self) -> list[str]:
|
||||
"""Get fallback index URLs."""
|
||||
if not self.extra_index_urls:
|
||||
return []
|
||||
|
||||
return [url.strip() for url in self.extra_index_urls.split(",") if url.strip()]
|
||||
|
||||
def get_safe_dict(self) -> dict[str, Any]:
|
||||
"""Get configuration as dictionary with sensitive data masked."""
|
||||
data = self.model_dump()
|
||||
# Mask sensitive information
|
||||
if data.get("private_pypi_password"):
|
||||
data["private_pypi_password"] = "***"
|
||||
return data
|
||||
|
||||
|
||||
# Global settings instance
|
||||
_settings: ServerSettings | None = None
|
||||
|
||||
|
||||
def get_settings() -> ServerSettings:
|
||||
"""Get global settings instance."""
|
||||
global _settings
|
||||
if _settings is None:
|
||||
_settings = ServerSettings()
|
||||
return _settings
|
||||
|
||||
|
||||
def reload_settings() -> ServerSettings:
|
||||
"""Reload settings from environment variables."""
|
||||
global _settings
|
||||
_settings = ServerSettings()
|
||||
return _settings
|
||||
|
||||
|
||||
def update_settings(**kwargs: Any) -> ServerSettings:
|
||||
"""Update settings with new values."""
|
||||
global _settings
|
||||
current_data = _settings.model_dump() if _settings else {}
|
||||
current_data.update(kwargs)
|
||||
_settings = ServerSettings(**current_data)
|
||||
return _settings
|
||||
Loading…
Add table
Add a link
Reference in a new issue