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
|
|
@ -19,7 +19,7 @@ def sample_package_data():
|
|||
"requires_dist": [
|
||||
"requests>=2.25.0",
|
||||
"click>=8.0.0",
|
||||
"pytest>=6.0.0; extra == 'test'"
|
||||
"pytest>=6.0.0; extra == 'test'",
|
||||
],
|
||||
"classifiers": [
|
||||
"Development Status :: 4 - Beta",
|
||||
|
|
@ -30,30 +30,30 @@ def sample_package_data():
|
|||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: Implementation :: CPython"
|
||||
]
|
||||
"Programming Language :: Python :: Implementation :: CPython",
|
||||
],
|
||||
},
|
||||
"releases": {
|
||||
"1.0.0": [
|
||||
{
|
||||
"filename": "test_package-1.0.0-py3-none-any.whl",
|
||||
"packagetype": "bdist_wheel",
|
||||
"python_version": "py3"
|
||||
"python_version": "py3",
|
||||
},
|
||||
{
|
||||
"filename": "test-package-1.0.0.tar.gz",
|
||||
"packagetype": "sdist",
|
||||
"python_version": "source"
|
||||
}
|
||||
"python_version": "source",
|
||||
},
|
||||
],
|
||||
"0.9.0": [
|
||||
{
|
||||
"filename": "test-package-0.9.0.tar.gz",
|
||||
"packagetype": "sdist",
|
||||
"python_version": "source"
|
||||
"python_version": "source",
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -47,7 +47,7 @@ def test_version_compatibility():
|
|||
classifiers = [
|
||||
"Programming Language :: Python :: 3.8",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: Implementation :: CPython"
|
||||
"Programming Language :: Python :: Implementation :: CPython",
|
||||
]
|
||||
|
||||
versions = compat.extract_python_versions_from_classifiers(classifiers)
|
||||
|
|
|
|||
386
tests/test_config.py
Normal file
386
tests/test_config.py
Normal file
|
|
@ -0,0 +1,386 @@
|
|||
"""Tests for configuration management."""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from pypi_query_mcp.config import (
|
||||
AuthType,
|
||||
RepositoryConfig,
|
||||
RepositoryManager,
|
||||
RepositoryType,
|
||||
ServerSettings,
|
||||
get_repository_manager,
|
||||
get_settings,
|
||||
reload_settings,
|
||||
)
|
||||
|
||||
|
||||
class TestServerSettings:
|
||||
"""Test ServerSettings class."""
|
||||
|
||||
def test_default_settings(self):
|
||||
"""Test default settings values."""
|
||||
settings = ServerSettings()
|
||||
|
||||
assert settings.log_level == "INFO"
|
||||
assert settings.cache_ttl == 3600
|
||||
assert settings.request_timeout == 30.0
|
||||
assert settings.max_retries == 3
|
||||
assert settings.retry_delay == 1.0
|
||||
assert settings.index_url == "https://pypi.org/pypi"
|
||||
assert settings.private_pypi_url is None
|
||||
assert settings.dependency_max_depth == 5
|
||||
assert settings.dependency_max_concurrent == 10
|
||||
assert settings.enable_security_analysis is False
|
||||
|
||||
def test_environment_variables(self):
|
||||
"""Test loading from environment variables."""
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"PYPI_LOG_LEVEL": "DEBUG",
|
||||
"PYPI_CACHE_TTL": "7200",
|
||||
"PYPI_PRIVATE_PYPI_URL": "https://private.pypi.com",
|
||||
"PYPI_PRIVATE_PYPI_USERNAME": "testuser",
|
||||
"PYPI_PRIVATE_PYPI_PASSWORD": "testpass",
|
||||
},
|
||||
):
|
||||
settings = ServerSettings()
|
||||
|
||||
assert settings.log_level == "DEBUG"
|
||||
assert settings.cache_ttl == 7200
|
||||
assert settings.private_pypi_url == "https://private.pypi.com"
|
||||
assert settings.private_pypi_username == "testuser"
|
||||
assert settings.private_pypi_password == "testpass"
|
||||
|
||||
def test_validation(self):
|
||||
"""Test settings validation."""
|
||||
# Test invalid log level
|
||||
with pytest.raises(ValueError, match="Invalid log level"):
|
||||
ServerSettings(log_level="INVALID")
|
||||
|
||||
# Test negative cache TTL
|
||||
with pytest.raises(ValueError, match="Cache TTL must be non-negative"):
|
||||
ServerSettings(cache_ttl=-1)
|
||||
|
||||
# Test invalid dependency max depth
|
||||
with pytest.raises(
|
||||
ValueError, match="Dependency max depth must be between 1 and 10"
|
||||
):
|
||||
ServerSettings(dependency_max_depth=0)
|
||||
|
||||
with pytest.raises(
|
||||
ValueError, match="Dependency max depth must be between 1 and 10"
|
||||
):
|
||||
ServerSettings(dependency_max_depth=11)
|
||||
|
||||
def test_has_private_repo(self):
|
||||
"""Test private repository detection."""
|
||||
settings = ServerSettings()
|
||||
assert not settings.has_private_repo()
|
||||
|
||||
settings = ServerSettings(private_pypi_url="https://private.pypi.com")
|
||||
assert settings.has_private_repo()
|
||||
|
||||
def test_has_private_auth(self):
|
||||
"""Test private authentication detection."""
|
||||
settings = ServerSettings()
|
||||
assert not settings.has_private_auth()
|
||||
|
||||
settings = ServerSettings(
|
||||
private_pypi_url="https://private.pypi.com",
|
||||
private_pypi_username="user",
|
||||
private_pypi_password="pass",
|
||||
)
|
||||
assert settings.has_private_auth()
|
||||
|
||||
def test_get_safe_dict(self):
|
||||
"""Test safe dictionary representation."""
|
||||
settings = ServerSettings(private_pypi_password="secret123")
|
||||
safe_dict = settings.get_safe_dict()
|
||||
assert safe_dict["private_pypi_password"] == "***"
|
||||
|
||||
def test_multiple_index_urls(self):
|
||||
"""Test multiple index URLs configuration."""
|
||||
settings = ServerSettings(
|
||||
index_url="https://pypi.org/pypi",
|
||||
index_urls="https://mirrors.aliyun.com/pypi/simple/,https://pypi.tuna.tsinghua.edu.cn/simple/",
|
||||
extra_index_urls="https://test.pypi.org/simple/",
|
||||
)
|
||||
|
||||
all_urls = settings.get_all_index_urls()
|
||||
assert len(all_urls) == 4
|
||||
assert all_urls[0] == "https://pypi.org/pypi"
|
||||
assert "https://mirrors.aliyun.com/pypi/simple/" in all_urls
|
||||
assert "https://pypi.tuna.tsinghua.edu.cn/simple/" in all_urls
|
||||
assert "https://test.pypi.org/simple/" in all_urls
|
||||
|
||||
primary_urls = settings.get_primary_index_urls()
|
||||
assert len(primary_urls) == 3
|
||||
assert "https://test.pypi.org/simple/" not in primary_urls
|
||||
|
||||
fallback_urls = settings.get_fallback_index_urls()
|
||||
assert len(fallback_urls) == 1
|
||||
assert fallback_urls[0] == "https://test.pypi.org/simple/"
|
||||
|
||||
def test_duplicate_urls_removal(self):
|
||||
"""Test duplicate URL removal while preserving order."""
|
||||
settings = ServerSettings(
|
||||
index_url="https://pypi.org/pypi",
|
||||
index_urls="https://pypi.org/pypi,https://mirrors.aliyun.com/pypi/simple/",
|
||||
extra_index_urls="https://mirrors.aliyun.com/pypi/simple/",
|
||||
)
|
||||
|
||||
all_urls = settings.get_all_index_urls()
|
||||
assert len(all_urls) == 2 # Duplicates removed
|
||||
assert all_urls[0] == "https://pypi.org/pypi"
|
||||
assert all_urls[1] == "https://mirrors.aliyun.com/pypi/simple/"
|
||||
|
||||
def test_empty_index_urls(self):
|
||||
"""Test handling of empty index URLs."""
|
||||
settings = ServerSettings(
|
||||
index_url="https://pypi.org/pypi", index_urls="", extra_index_urls=None
|
||||
)
|
||||
|
||||
all_urls = settings.get_all_index_urls()
|
||||
assert len(all_urls) == 1
|
||||
assert all_urls[0] == "https://pypi.org/pypi"
|
||||
|
||||
fallback_urls = settings.get_fallback_index_urls()
|
||||
assert len(fallback_urls) == 0
|
||||
|
||||
|
||||
class TestRepositoryConfig:
|
||||
"""Test RepositoryConfig class."""
|
||||
|
||||
def test_basic_repository(self):
|
||||
"""Test basic repository configuration."""
|
||||
repo = RepositoryConfig(
|
||||
name="test", url="https://test.pypi.com", type=RepositoryType.PRIVATE
|
||||
)
|
||||
|
||||
assert repo.name == "test"
|
||||
assert repo.url == "https://test.pypi.com"
|
||||
assert repo.type == RepositoryType.PRIVATE
|
||||
assert repo.priority == 100
|
||||
assert repo.auth_type == AuthType.NONE
|
||||
assert repo.enabled is True
|
||||
|
||||
def test_repository_with_auth(self):
|
||||
"""Test repository with authentication."""
|
||||
repo = RepositoryConfig(
|
||||
name="private",
|
||||
url="https://private.pypi.com",
|
||||
type=RepositoryType.PRIVATE,
|
||||
auth_type=AuthType.BASIC,
|
||||
username="user",
|
||||
password="pass",
|
||||
)
|
||||
|
||||
assert repo.requires_auth()
|
||||
assert repo.has_credentials()
|
||||
|
||||
def test_repository_validation(self):
|
||||
"""Test repository validation."""
|
||||
# Test invalid priority
|
||||
with pytest.raises(ValueError, match="Priority must be between 1 and 1000"):
|
||||
RepositoryConfig(
|
||||
name="test",
|
||||
url="https://test.com",
|
||||
type=RepositoryType.PUBLIC,
|
||||
priority=0,
|
||||
)
|
||||
|
||||
# Test invalid timeout
|
||||
with pytest.raises(ValueError, match="Timeout must be positive"):
|
||||
RepositoryConfig(
|
||||
name="test",
|
||||
url="https://test.com",
|
||||
type=RepositoryType.PUBLIC,
|
||||
timeout=0,
|
||||
)
|
||||
|
||||
def test_get_safe_dict(self):
|
||||
"""Test safe dictionary representation."""
|
||||
repo = RepositoryConfig(
|
||||
name="test",
|
||||
url="https://test.com",
|
||||
type=RepositoryType.PRIVATE,
|
||||
auth_type=AuthType.BASIC,
|
||||
username="user",
|
||||
password="secret123",
|
||||
)
|
||||
safe_dict = repo.get_safe_dict()
|
||||
assert safe_dict["password"] == "***"
|
||||
|
||||
|
||||
class TestRepositoryManager:
|
||||
"""Test RepositoryManager class."""
|
||||
|
||||
def test_default_repositories(self):
|
||||
"""Test default repository loading."""
|
||||
manager = RepositoryManager()
|
||||
repos = manager.list_repositories()
|
||||
|
||||
assert len(repos) == 1
|
||||
assert repos[0].name == "pypi"
|
||||
assert repos[0].type == RepositoryType.PUBLIC
|
||||
|
||||
def test_add_repository(self):
|
||||
"""Test adding repository."""
|
||||
manager = RepositoryManager()
|
||||
|
||||
repo = RepositoryConfig(
|
||||
name="private",
|
||||
url="https://private.pypi.com",
|
||||
type=RepositoryType.PRIVATE,
|
||||
priority=1,
|
||||
)
|
||||
|
||||
manager.add_repository(repo)
|
||||
assert len(manager.list_repositories()) == 2
|
||||
assert manager.get_repository("private") == repo
|
||||
|
||||
def test_remove_repository(self):
|
||||
"""Test removing repository."""
|
||||
manager = RepositoryManager()
|
||||
|
||||
# Cannot remove default PyPI
|
||||
with pytest.raises(ValueError, match="Cannot remove default PyPI repository"):
|
||||
manager.remove_repository("pypi")
|
||||
|
||||
# Add and remove custom repository
|
||||
repo = RepositoryConfig(
|
||||
name="test", url="https://test.com", type=RepositoryType.PRIVATE
|
||||
)
|
||||
manager.add_repository(repo)
|
||||
assert manager.get_repository("test") is not None
|
||||
|
||||
manager.remove_repository("test")
|
||||
assert manager.get_repository("test") is None
|
||||
|
||||
def test_get_enabled_repositories(self):
|
||||
"""Test getting enabled repositories sorted by priority."""
|
||||
manager = RepositoryManager()
|
||||
|
||||
# Add repositories with different priorities
|
||||
repo1 = RepositoryConfig(
|
||||
name="high_priority",
|
||||
url="https://high.com",
|
||||
type=RepositoryType.PRIVATE,
|
||||
priority=1,
|
||||
)
|
||||
repo2 = RepositoryConfig(
|
||||
name="low_priority",
|
||||
url="https://low.com",
|
||||
type=RepositoryType.PRIVATE,
|
||||
priority=200,
|
||||
)
|
||||
|
||||
manager.add_repository(repo1)
|
||||
manager.add_repository(repo2)
|
||||
|
||||
enabled = manager.get_enabled_repositories()
|
||||
assert len(enabled) == 3 # Including default PyPI
|
||||
assert enabled[0].name == "high_priority" # Highest priority first
|
||||
assert enabled[1].name == "pypi"
|
||||
assert enabled[2].name == "low_priority"
|
||||
|
||||
def test_private_repositories(self):
|
||||
"""Test private repository management."""
|
||||
manager = RepositoryManager()
|
||||
|
||||
assert not manager.has_private_repositories()
|
||||
assert len(manager.get_private_repositories()) == 0
|
||||
|
||||
# Add private repository
|
||||
repo = RepositoryConfig(
|
||||
name="private", url="https://private.com", type=RepositoryType.PRIVATE
|
||||
)
|
||||
manager.add_repository(repo)
|
||||
|
||||
assert manager.has_private_repositories()
|
||||
assert len(manager.get_private_repositories()) == 1
|
||||
|
||||
def test_add_private_repository_from_settings(self):
|
||||
"""Test adding private repository from settings."""
|
||||
manager = RepositoryManager()
|
||||
|
||||
# Add without auth
|
||||
manager.add_private_repository_from_settings("https://private.com")
|
||||
private_repos = manager.get_private_repositories()
|
||||
assert len(private_repos) == 1
|
||||
assert private_repos[0].auth_type == AuthType.NONE
|
||||
|
||||
# Add with auth
|
||||
manager = RepositoryManager()
|
||||
manager.add_private_repository_from_settings(
|
||||
"https://private.com", "user", "pass"
|
||||
)
|
||||
private_repos = manager.get_private_repositories()
|
||||
assert len(private_repos) == 1
|
||||
assert private_repos[0].auth_type == AuthType.BASIC
|
||||
assert private_repos[0].username == "user"
|
||||
|
||||
def test_load_repositories_from_settings(self):
|
||||
"""Test loading repositories from settings."""
|
||||
manager = RepositoryManager()
|
||||
|
||||
# Create settings with multiple index URLs
|
||||
settings = ServerSettings(
|
||||
index_url="https://custom.pypi.org/pypi",
|
||||
index_urls="https://mirrors.aliyun.com/pypi/simple/",
|
||||
extra_index_urls="https://test.pypi.org/simple/",
|
||||
private_pypi_url="https://private.pypi.com",
|
||||
private_pypi_username="user",
|
||||
private_pypi_password="pass",
|
||||
)
|
||||
|
||||
manager.load_repositories_from_settings(settings)
|
||||
|
||||
# Check all repositories are loaded
|
||||
all_repos = manager.list_repositories()
|
||||
repo_names = [repo.name for repo in all_repos]
|
||||
|
||||
assert "pypi" in repo_names # Primary
|
||||
assert "index_1" in repo_names # Additional index
|
||||
assert "fallback_0" in repo_names # Fallback
|
||||
assert "private" in repo_names # Private repo
|
||||
|
||||
# Check primary PyPI URL is updated
|
||||
pypi_repo = manager.get_repository("pypi")
|
||||
assert pypi_repo.url == "https://custom.pypi.org/pypi"
|
||||
|
||||
# Check priorities are correct
|
||||
enabled_repos = manager.get_enabled_repositories()
|
||||
priorities = [repo.priority for repo in enabled_repos]
|
||||
assert priorities == sorted(priorities) # Should be sorted by priority
|
||||
|
||||
# Check private repository has auth
|
||||
private_repo = manager.get_repository("private")
|
||||
assert private_repo.auth_type == AuthType.BASIC
|
||||
assert private_repo.username == "user"
|
||||
|
||||
|
||||
class TestGlobalInstances:
|
||||
"""Test global configuration instances."""
|
||||
|
||||
def test_get_settings(self):
|
||||
"""Test global settings instance."""
|
||||
settings1 = get_settings()
|
||||
settings2 = get_settings()
|
||||
assert settings1 is settings2 # Same instance
|
||||
|
||||
def test_reload_settings(self):
|
||||
"""Test settings reload."""
|
||||
settings1 = get_settings()
|
||||
settings2 = reload_settings()
|
||||
assert settings1 is not settings2 # Different instance
|
||||
|
||||
def test_get_repository_manager(self):
|
||||
"""Test global repository manager instance."""
|
||||
manager1 = get_repository_manager()
|
||||
manager2 = get_repository_manager()
|
||||
assert manager1 is manager2 # Same instance
|
||||
Loading…
Add table
Add a link
Reference in a new issue