mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
Fix 35 ruff errors (E722, F841, B904, B905, B007, E741, S202, S310, S306, S110, TID251, UP042, F401) and run black on 526 files. Co-authored-by: Hanzo Dev <[email protected]>
321 lines
10 KiB
Python
321 lines
10 KiB
Python
"""Credential manager with pluggable storage.
|
|
|
|
Provides unified credential management with:
|
|
- Explicit resolution order (override > memory > file > env)
|
|
- Pluggable storage backends
|
|
- Clear source introspection
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from .errors import CredentialError
|
|
from .models import (
|
|
Credential,
|
|
CredentialSource,
|
|
EffectiveCredential,
|
|
ProviderConfig,
|
|
ProviderStatus,
|
|
)
|
|
from .providers import (
|
|
ENV_VAR_MAPPINGS,
|
|
PROVIDER_CONFIGS,
|
|
get_env_vars,
|
|
get_provider_config,
|
|
)
|
|
from .storage import (
|
|
ChainedCredentialStorage,
|
|
CredentialStorage,
|
|
EnvironmentCredentialStorage,
|
|
FileCredentialStorage,
|
|
MemoryCredentialStorage,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class CredentialManager:
|
|
"""Manages API credentials with explicit resolution order.
|
|
|
|
Resolution order:
|
|
1. Per-call overrides (via override_for_provider)
|
|
2. In-memory config (set at runtime)
|
|
3. Stored file (~/.hanzo/api/credentials.json)
|
|
4. Environment variables
|
|
|
|
Example:
|
|
manager = CredentialManager()
|
|
|
|
# Check what credentials are available
|
|
effective = await manager.get_effective_credentials("cloudflare")
|
|
print(f"Source: {effective.source}") # e.g., "environment"
|
|
|
|
# Configure credentials
|
|
await manager.set_credential("cloudflare", api_key="my-key")
|
|
|
|
# Make a call with per-call override
|
|
async with manager.override_for_provider("cloudflare", api_key="temp-key"):
|
|
# Uses temp-key for this call only
|
|
cred = await manager.get_credential("cloudflare")
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
config_dir: Path | None = None,
|
|
storage: CredentialStorage | None = None,
|
|
):
|
|
"""Initialize credential manager.
|
|
|
|
Args:
|
|
config_dir: Directory for file storage. Defaults to ~/.hanzo/api/
|
|
storage: Custom storage backend. If None, uses default chain.
|
|
"""
|
|
self.config_dir = config_dir or Path.home() / ".hanzo" / "api"
|
|
self.specs_dir = self.config_dir / "specs"
|
|
|
|
# Set up storage chain
|
|
# Note: MemoryCredentialStorage is NOT included here because
|
|
# per-call overrides are handled separately by self._overrides
|
|
if storage:
|
|
self._storage = storage
|
|
else:
|
|
file_path = self.config_dir / "credentials.json"
|
|
self._storage = ChainedCredentialStorage(
|
|
[
|
|
FileCredentialStorage(file_path), # Persistent storage
|
|
EnvironmentCredentialStorage(
|
|
ENV_VAR_MAPPINGS
|
|
), # Read-only fallback
|
|
]
|
|
)
|
|
|
|
# Separate override storage for context manager
|
|
self._overrides = MemoryCredentialStorage()
|
|
|
|
async def get_credential(self, provider: str) -> Credential:
|
|
"""Get credential for a provider.
|
|
|
|
Args:
|
|
provider: Provider name
|
|
|
|
Returns:
|
|
Credential (may be empty if not found)
|
|
"""
|
|
effective = await self.get_effective_credentials(provider)
|
|
return effective.credential
|
|
|
|
async def get_effective_credentials(self, provider: str) -> EffectiveCredential:
|
|
"""Get credential with full resolution info.
|
|
|
|
Shows exactly where the credential came from and what
|
|
alternatives are available.
|
|
|
|
Args:
|
|
provider: Provider name
|
|
|
|
Returns:
|
|
EffectiveCredential with source information
|
|
"""
|
|
# Check overrides first
|
|
override = await self._overrides.get(provider)
|
|
if override and override.has_credentials:
|
|
return EffectiveCredential(
|
|
credential=override,
|
|
source=CredentialSource.OVERRIDE,
|
|
)
|
|
|
|
# Check chained storage
|
|
if isinstance(self._storage, ChainedCredentialStorage):
|
|
cred, store = await self._storage.get_with_source(provider)
|
|
if cred and cred.has_credentials:
|
|
source = CredentialSource.NONE
|
|
env_var = None
|
|
|
|
if isinstance(store, MemoryCredentialStorage):
|
|
source = CredentialSource.MEMORY
|
|
elif isinstance(store, FileCredentialStorage):
|
|
source = CredentialSource.STORED
|
|
elif isinstance(store, EnvironmentCredentialStorage):
|
|
source = CredentialSource.ENVIRONMENT
|
|
# Find which env var was used
|
|
for var in get_env_vars(provider):
|
|
if os.environ.get(var):
|
|
env_var = var
|
|
break
|
|
|
|
return EffectiveCredential(
|
|
credential=cred,
|
|
source=source,
|
|
env_var_used=env_var,
|
|
)
|
|
else:
|
|
cred = await self._storage.get(provider)
|
|
if cred and cred.has_credentials:
|
|
return EffectiveCredential(
|
|
credential=cred,
|
|
source=CredentialSource.STORED,
|
|
)
|
|
|
|
# Check provider config for base URL
|
|
config = get_provider_config(provider)
|
|
base_url = config.base_url if config else None
|
|
|
|
return EffectiveCredential(
|
|
credential=Credential(provider=provider, base_url=base_url),
|
|
source=CredentialSource.NONE,
|
|
)
|
|
|
|
async def set_credential(
|
|
self,
|
|
provider: str,
|
|
api_key: str | None = None,
|
|
api_secret: str | None = None,
|
|
account_id: str | None = None,
|
|
base_url: str | None = None,
|
|
**extra,
|
|
) -> None:
|
|
"""Store a credential.
|
|
|
|
Args:
|
|
provider: Provider name
|
|
api_key: API key or token
|
|
api_secret: API secret (for providers that need it)
|
|
account_id: Account/organization ID
|
|
base_url: Custom base URL override
|
|
**extra: Additional provider-specific fields
|
|
"""
|
|
# Get existing to merge
|
|
existing = await self._storage.get(provider)
|
|
|
|
credential = Credential(
|
|
provider=provider,
|
|
api_key=api_key or (existing.api_key if existing else None),
|
|
api_secret=api_secret or (existing.api_secret if existing else None),
|
|
account_id=account_id or (existing.account_id if existing else None),
|
|
base_url=base_url or (existing.base_url if existing else None),
|
|
extra={**(existing.extra if existing else {}), **extra},
|
|
)
|
|
|
|
await self._storage.set(credential)
|
|
|
|
async def delete_credential(self, provider: str) -> bool:
|
|
"""Delete a stored credential.
|
|
|
|
Args:
|
|
provider: Provider name
|
|
|
|
Returns:
|
|
True if deleted, False if not found
|
|
"""
|
|
return await self._storage.delete(provider)
|
|
|
|
async def list_credentials(self) -> list[str]:
|
|
"""List all providers with stored credentials."""
|
|
return await self._storage.list()
|
|
|
|
async def list_providers(self) -> list[ProviderStatus]:
|
|
"""List all providers and their status.
|
|
|
|
Returns:
|
|
List of provider status objects
|
|
"""
|
|
result = []
|
|
all_providers = set(PROVIDER_CONFIGS.keys()) | set(ENV_VAR_MAPPINGS.keys())
|
|
|
|
# Add stored providers too
|
|
stored = await self._storage.list()
|
|
all_providers.update(stored)
|
|
|
|
for provider in sorted(all_providers):
|
|
effective = await self.get_effective_credentials(provider)
|
|
config = get_provider_config(provider)
|
|
|
|
# Check spec cache
|
|
spec_cached = False
|
|
spec_age = None
|
|
spec_file = self.specs_dir / f"{provider}.json"
|
|
if spec_file.exists():
|
|
spec_cached = True
|
|
import time
|
|
|
|
spec_age = time.time() - spec_file.stat().st_mtime
|
|
|
|
result.append(
|
|
ProviderStatus(
|
|
name=provider,
|
|
display_name=config.display_name if config else provider.title(),
|
|
configured=effective.has_credentials,
|
|
source=effective.source if effective.has_credentials else None,
|
|
base_url=config.base_url if config else "",
|
|
has_spec=bool(config and config.spec_url),
|
|
spec_cached=spec_cached,
|
|
spec_age_seconds=spec_age,
|
|
)
|
|
)
|
|
|
|
return result
|
|
|
|
async def require_credential(self, provider: str) -> Credential:
|
|
"""Get credential or raise CredentialError.
|
|
|
|
Args:
|
|
provider: Provider name
|
|
|
|
Returns:
|
|
Credential with valid api_key
|
|
|
|
Raises:
|
|
CredentialError: If no credential is configured
|
|
"""
|
|
effective = await self.get_effective_credentials(provider)
|
|
|
|
if not effective.has_credentials:
|
|
env_vars = get_env_vars(provider)
|
|
raise CredentialError(
|
|
message=f"No credentials configured for {provider}",
|
|
provider=provider,
|
|
env_vars=env_vars,
|
|
)
|
|
|
|
return effective.credential
|
|
|
|
def get_provider_config(self, provider: str) -> ProviderConfig | None:
|
|
"""Get provider configuration."""
|
|
return get_provider_config(provider)
|
|
|
|
|
|
# Singleton instance
|
|
_credential_manager: CredentialManager | None = None
|
|
|
|
|
|
def get_credential_manager() -> CredentialManager:
|
|
"""Get the global credential manager instance."""
|
|
global _credential_manager
|
|
if _credential_manager is None:
|
|
_credential_manager = CredentialManager()
|
|
return _credential_manager
|
|
|
|
|
|
def reset_credential_manager() -> None:
|
|
"""Reset the global credential manager (for testing)."""
|
|
global _credential_manager
|
|
_credential_manager = None
|
|
|
|
|
|
# Re-export for backwards compatibility
|
|
__all__ = [
|
|
"CredentialManager",
|
|
"get_credential_manager",
|
|
"reset_credential_manager",
|
|
"Credential",
|
|
"EffectiveCredential",
|
|
"CredentialSource",
|
|
"ProviderConfig",
|
|
"ProviderStatus",
|
|
"PROVIDER_CONFIGS",
|
|
"ENV_VAR_MAPPINGS",
|
|
]
|