feat: implement modular capability system with automatic discovery
- Move release management to capabilities/release-management/ with complete Makefile - Create automatic capability discovery system in scripts/capability_discovery.mk - Add capability-manager subagent for managing modular architecture - Implement target delegation system enabling capability-name-target patterns - Create Makefiles for markitect-content, markitect-utils, and issue-facade capabilities - Remove legacy release management code and documentation from main project - Update main Makefile to use capability discovery and delegation - Add comprehensive capability status, help, and management targets The capability system provides: - Automatic discovery of capabilities with Makefiles - Clean target delegation without conflicts - Modular architecture following established patterns - Comprehensive help and status reporting - Zero-conflict capability integration 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Package registry implementations.
|
||||
|
||||
This module provides registry clients for publishing packages to various platforms:
|
||||
- Gitea package registries
|
||||
- PyPI and Test PyPI
|
||||
- Extensible factory for custom registries
|
||||
"""
|
||||
|
||||
from .factory import RegistryFactory
|
||||
from .base import RegistryInterface, RegistryConfig
|
||||
from .gitea.registry import GiteaRegistry
|
||||
from .gitea.config import GiteaConfig
|
||||
from .gitea.exceptions import GiteaError
|
||||
|
||||
__all__ = [
|
||||
"RegistryFactory",
|
||||
"RegistryInterface",
|
||||
"RegistryConfig",
|
||||
"GiteaRegistry",
|
||||
"GiteaConfig",
|
||||
"GiteaError",
|
||||
]
|
||||
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
Base registry interface and configuration.
|
||||
|
||||
This module defines the common interface that all registry implementations must follow.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Any
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class RegistryConfig:
|
||||
"""Base configuration for package registries."""
|
||||
name: str
|
||||
type: str
|
||||
url: str
|
||||
auth_token_env: Optional[str] = None
|
||||
|
||||
def get_auth_token(self) -> Optional[str]:
|
||||
"""Get authentication token from environment variable."""
|
||||
if self.auth_token_env:
|
||||
import os
|
||||
return os.getenv(self.auth_token_env)
|
||||
return None
|
||||
|
||||
|
||||
class RegistryInterface(ABC):
|
||||
"""Abstract interface for package registries."""
|
||||
|
||||
def __init__(self, config: RegistryConfig):
|
||||
"""Initialize the registry with configuration."""
|
||||
self.config = config
|
||||
|
||||
@abstractmethod
|
||||
def upload_package(self, package_path: Path, dry_run: bool = False) -> bool:
|
||||
"""Upload a package to the registry.
|
||||
|
||||
Args:
|
||||
package_path: Path to package file (.whl or .tar.gz)
|
||||
dry_run: If True, show what would be done without uploading
|
||||
|
||||
Returns:
|
||||
True if upload successful, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def check_auth(self) -> bool:
|
||||
"""Check if authentication is properly configured.
|
||||
|
||||
Returns:
|
||||
True if authenticated, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def list_packages(self) -> List[Dict[str, Any]]:
|
||||
"""List packages in the registry.
|
||||
|
||||
Returns:
|
||||
List of package information dictionaries
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_package_info(self, package_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get information about a specific package.
|
||||
|
||||
Args:
|
||||
package_name: Name of the package
|
||||
|
||||
Returns:
|
||||
Package information dictionary or None if not found
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def delete_package_version(self, package_name: str, version: str,
|
||||
dry_run: bool = False) -> bool:
|
||||
"""Delete a specific version of a package.
|
||||
|
||||
Args:
|
||||
package_name: Name of the package
|
||||
version: Version to delete
|
||||
dry_run: If True, show what would be done without deleting
|
||||
|
||||
Returns:
|
||||
True if deletion successful, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_registry_info(self) -> Dict[str, Any]:
|
||||
"""Get information about the registry configuration.
|
||||
|
||||
Returns:
|
||||
Dictionary with registry information
|
||||
"""
|
||||
pass
|
||||
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
Registry factory for creating registry clients.
|
||||
|
||||
This module provides a factory for creating appropriate registry clients
|
||||
based on configuration or registry type.
|
||||
"""
|
||||
|
||||
from typing import Dict, Type, Optional, Any
|
||||
from pathlib import Path
|
||||
|
||||
from .base import RegistryInterface, RegistryConfig
|
||||
from .gitea.registry import GiteaRegistry
|
||||
from .gitea.config import GiteaConfig
|
||||
|
||||
|
||||
class RegistryFactory:
|
||||
"""Factory for creating registry clients."""
|
||||
|
||||
_registry_types: Dict[str, Type[RegistryInterface]] = {
|
||||
'gitea': GiteaRegistry,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def create(cls, registry_type: str, config: Optional[Dict[str, Any]] = None) -> RegistryInterface:
|
||||
"""Create a registry client of the specified type.
|
||||
|
||||
Args:
|
||||
registry_type: Type of registry ('gitea', 'pypi', etc.)
|
||||
config: Optional configuration dictionary
|
||||
|
||||
Returns:
|
||||
Registry client instance
|
||||
|
||||
Raises:
|
||||
ValueError: If registry type is not supported
|
||||
"""
|
||||
if registry_type not in cls._registry_types:
|
||||
raise ValueError(f"Unsupported registry type: {registry_type}. "
|
||||
f"Supported types: {list(cls._registry_types.keys())}")
|
||||
|
||||
registry_class = cls._registry_types[registry_type]
|
||||
|
||||
# Handle Gitea-specific configuration
|
||||
if registry_type == 'gitea':
|
||||
if config:
|
||||
gitea_config = GiteaConfig(
|
||||
gitea_url=config.get('url', ''),
|
||||
repo_owner=config.get('owner', ''),
|
||||
repo_name=config.get('repo', ''),
|
||||
auth_token=config.get('auth_token')
|
||||
)
|
||||
return registry_class(gitea_config)
|
||||
else:
|
||||
# Auto-detect from git repository
|
||||
return registry_class()
|
||||
|
||||
# For other registry types, create with generic config
|
||||
if config:
|
||||
registry_config = RegistryConfig(
|
||||
name=config.get('name', registry_type),
|
||||
type=registry_type,
|
||||
url=config['url'],
|
||||
auth_token_env=config.get('auth_token_env')
|
||||
)
|
||||
return registry_class(registry_config)
|
||||
else:
|
||||
raise ValueError(f"Configuration required for {registry_type} registry")
|
||||
|
||||
@classmethod
|
||||
def create_from_pyproject_config(cls, pyproject_path: Optional[Path] = None,
|
||||
registry_name: str = 'gitea') -> RegistryInterface:
|
||||
"""Create a registry client from pyproject.toml configuration.
|
||||
|
||||
Args:
|
||||
pyproject_path: Path to pyproject.toml file. If None, looks in current directory.
|
||||
registry_name: Name of registry configuration to use
|
||||
|
||||
Returns:
|
||||
Registry client instance
|
||||
|
||||
Raises:
|
||||
ValueError: If configuration is invalid or registry not found
|
||||
"""
|
||||
if pyproject_path is None:
|
||||
pyproject_path = Path.cwd() / "pyproject.toml"
|
||||
|
||||
if not pyproject_path.exists():
|
||||
raise ValueError(f"pyproject.toml not found at {pyproject_path}")
|
||||
|
||||
try:
|
||||
import tomllib
|
||||
except ImportError:
|
||||
try:
|
||||
import tomli as tomllib # Fallback for Python < 3.11
|
||||
except ImportError:
|
||||
raise ImportError("tomllib or tomli required to read pyproject.toml")
|
||||
|
||||
with open(pyproject_path, 'rb') as f:
|
||||
config = tomllib.load(f)
|
||||
|
||||
# Look for release-management configuration
|
||||
release_config = config.get('tool', {}).get('release-management', {})
|
||||
registries_config = release_config.get('registries', {})
|
||||
|
||||
if registry_name not in registries_config:
|
||||
raise ValueError(f"Registry '{registry_name}' not found in pyproject.toml configuration")
|
||||
|
||||
registry_config = registries_config[registry_name]
|
||||
registry_type = registry_config.get('type', registry_name)
|
||||
|
||||
# Add auth token from environment if specified
|
||||
auth_token_env = registry_config.get('auth_token_env')
|
||||
if auth_token_env:
|
||||
import os
|
||||
registry_config = registry_config.copy()
|
||||
registry_config['auth_token'] = os.getenv(auth_token_env)
|
||||
|
||||
return cls.create(registry_type, registry_config)
|
||||
|
||||
@classmethod
|
||||
def auto_detect(cls) -> RegistryInterface:
|
||||
"""Auto-detect registry type from current environment.
|
||||
|
||||
Currently only supports Gitea auto-detection from git repository.
|
||||
|
||||
Returns:
|
||||
Registry client instance
|
||||
|
||||
Raises:
|
||||
ValueError: If no registry can be auto-detected
|
||||
"""
|
||||
# Try Gitea auto-detection first
|
||||
try:
|
||||
return cls.create('gitea')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
raise ValueError("Could not auto-detect registry type. "
|
||||
"Ensure you're in a git repository with Gitea remote, "
|
||||
"or provide explicit configuration.")
|
||||
|
||||
@classmethod
|
||||
def register_registry_type(cls, registry_type: str, registry_class: Type[RegistryInterface]) -> None:
|
||||
"""Register a new registry type.
|
||||
|
||||
Args:
|
||||
registry_type: String identifier for the registry type
|
||||
registry_class: Registry class that implements RegistryInterface
|
||||
"""
|
||||
cls._registry_types[registry_type] = registry_class
|
||||
|
||||
@classmethod
|
||||
def list_supported_types(cls) -> list[str]:
|
||||
"""List all supported registry types.
|
||||
|
||||
Returns:
|
||||
List of supported registry type strings
|
||||
"""
|
||||
return list(cls._registry_types.keys())
|
||||
@@ -0,0 +1,14 @@
|
||||
"""
|
||||
Gitea package registry implementation.
|
||||
|
||||
This module provides Gitea-specific registry functionality including:
|
||||
- Package registry uploads
|
||||
- Release asset fallback
|
||||
- Configuration and authentication
|
||||
"""
|
||||
|
||||
from .registry import GiteaRegistry
|
||||
from .config import GiteaConfig
|
||||
from .exceptions import GiteaError, GiteaConfigError
|
||||
|
||||
__all__ = ["GiteaRegistry", "GiteaConfig", "GiteaError", "GiteaConfigError"]
|
||||
@@ -0,0 +1,175 @@
|
||||
"""
|
||||
Gitea-specific configuration management.
|
||||
"""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .exceptions import GiteaConfigError
|
||||
|
||||
|
||||
def load_dotenv_file(env_file: Path) -> None:
|
||||
"""Load environment variables from a .env file."""
|
||||
if not env_file.exists():
|
||||
return
|
||||
|
||||
with open(env_file, 'r') as f:
|
||||
for line in f:
|
||||
line = line.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
key, value = line.split('=', 1)
|
||||
os.environ.setdefault(key.strip(), value.strip())
|
||||
|
||||
|
||||
@dataclass
|
||||
class GiteaConfig:
|
||||
"""Configuration for Gitea API access."""
|
||||
|
||||
# Repository settings (required)
|
||||
gitea_url: str = ""
|
||||
repo_owner: str = ""
|
||||
repo_name: str = ""
|
||||
|
||||
# Authentication (optional for read operations)
|
||||
auth_token: Optional[str] = None
|
||||
|
||||
@property
|
||||
def base_api_url(self) -> str:
|
||||
"""Get the base API URL for this repository."""
|
||||
return f"{self.gitea_url}/api/v1"
|
||||
|
||||
@property
|
||||
def repo_api_url(self) -> str:
|
||||
"""Get the repository API URL."""
|
||||
return f"{self.base_api_url}/repos/{self.repo_owner}/{self.repo_name}"
|
||||
|
||||
@property
|
||||
def issues_api_url(self) -> str:
|
||||
"""Get the issues API URL."""
|
||||
return f"{self.repo_api_url}/issues"
|
||||
|
||||
@property
|
||||
def milestones_api_url(self) -> str:
|
||||
"""Get the milestones API URL."""
|
||||
return f"{self.repo_api_url}/milestones"
|
||||
|
||||
@property
|
||||
def labels_api_url(self) -> str:
|
||||
"""Get the labels API URL."""
|
||||
return f"{self.repo_api_url}/labels"
|
||||
|
||||
@classmethod
|
||||
def from_environment(cls, env_prefix: str = "GITEA") -> "GiteaConfig":
|
||||
"""Create config from environment variables.
|
||||
|
||||
Args:
|
||||
env_prefix: Environment variable prefix (default: GITEA)
|
||||
Looks for {prefix}_URL, {prefix}_REPO_OWNER, etc.
|
||||
"""
|
||||
# Auto-load .env.gitea file if it exists
|
||||
env_file = Path(".env.gitea")
|
||||
load_dotenv_file(env_file)
|
||||
|
||||
config = cls()
|
||||
|
||||
# Load from environment
|
||||
config.gitea_url = os.getenv(f"{env_prefix}_URL", "")
|
||||
config.repo_owner = os.getenv(f"{env_prefix}_REPO_OWNER", "")
|
||||
config.repo_name = os.getenv(f"{env_prefix}_REPO_NAME", "")
|
||||
config.auth_token = os.getenv(f"{env_prefix}_API_TOKEN")
|
||||
|
||||
return config
|
||||
|
||||
@classmethod
|
||||
def from_git_repository(cls) -> "GiteaConfig":
|
||||
"""Create config by auto-detecting from current git repository.
|
||||
|
||||
Only requires GITEA_API_TOKEN environment variable.
|
||||
All other settings are detected from git remote origin.
|
||||
"""
|
||||
try:
|
||||
# Get git remote origin URL
|
||||
result = subprocess.run(
|
||||
['git', 'remote', 'get-url', 'origin'],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True
|
||||
)
|
||||
origin_url = result.stdout.strip()
|
||||
|
||||
# Parse different URL formats
|
||||
if origin_url.startswith('http://') or origin_url.startswith('https://'):
|
||||
# HTTP(S) format: https://gitea.example.com/owner/repo.git
|
||||
parsed = urlparse(origin_url)
|
||||
gitea_url = f"{parsed.scheme}://{parsed.netloc}"
|
||||
path_parts = parsed.path.strip('/').split('/')
|
||||
if len(path_parts) >= 2:
|
||||
repo_owner = path_parts[0]
|
||||
repo_name = path_parts[1].replace('.git', '')
|
||||
else:
|
||||
raise GiteaConfigError(f"Cannot parse repository path from URL: {origin_url}")
|
||||
elif '@' in origin_url:
|
||||
# SSH format: git@gitea.example.com:owner/repo.git
|
||||
if ':' in origin_url:
|
||||
host_part, path_part = origin_url.split(':', 1)
|
||||
host = host_part.split('@')[-1]
|
||||
gitea_url = f"https://{host}" # Assume HTTPS for API
|
||||
path_parts = path_part.strip('/').split('/')
|
||||
if len(path_parts) >= 2:
|
||||
repo_owner = path_parts[0]
|
||||
repo_name = path_parts[1].replace('.git', '')
|
||||
else:
|
||||
raise GiteaConfigError(f"Cannot parse repository path from SSH URL: {origin_url}")
|
||||
else:
|
||||
raise GiteaConfigError(f"Cannot parse SSH URL format: {origin_url}")
|
||||
else:
|
||||
raise GiteaConfigError(f"Unsupported git remote URL format: {origin_url}")
|
||||
|
||||
# Get auth token from environment
|
||||
auth_token = os.getenv('GITEA_API_TOKEN')
|
||||
|
||||
return cls(
|
||||
gitea_url=gitea_url,
|
||||
repo_owner=repo_owner,
|
||||
repo_name=repo_name,
|
||||
auth_token=auth_token
|
||||
)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise GiteaConfigError(f"Failed to get git remote origin: {e}")
|
||||
except Exception as e:
|
||||
raise GiteaConfigError(f"Failed to auto-detect git repository config: {e}")
|
||||
|
||||
@classmethod
|
||||
def from_tddai_config(cls, tddai_config) -> "GiteaConfig":
|
||||
"""Create GiteaConfig from legacy TddaiConfig for backwards compatibility."""
|
||||
return cls(
|
||||
gitea_url=tddai_config.gitea_url,
|
||||
repo_owner=tddai_config.repo_owner,
|
||||
repo_name=tddai_config.repo_name,
|
||||
auth_token=os.getenv('GITEA_API_TOKEN')
|
||||
)
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Validate configuration settings."""
|
||||
if not self.gitea_url:
|
||||
raise GiteaConfigError("gitea_url cannot be empty")
|
||||
|
||||
if not self.repo_owner:
|
||||
raise GiteaConfigError("repo_owner cannot be empty")
|
||||
|
||||
if not self.repo_name:
|
||||
raise GiteaConfigError("repo_name cannot be empty")
|
||||
|
||||
# Validate URL format
|
||||
if not (self.gitea_url.startswith('http://') or self.gitea_url.startswith('https://')):
|
||||
raise GiteaConfigError("gitea_url must start with http:// or https://")
|
||||
|
||||
def requires_auth(self, operation: str = "read") -> bool:
|
||||
"""Check if operation requires authentication."""
|
||||
write_operations = {"create", "update", "delete", "write"}
|
||||
return operation in write_operations and not self.auth_token
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Gitea-specific exceptions.
|
||||
"""
|
||||
|
||||
|
||||
class GiteaError(Exception):
|
||||
"""Base class for Gitea-related errors."""
|
||||
pass
|
||||
|
||||
|
||||
class GiteaConfigError(GiteaError):
|
||||
"""Configuration-related errors."""
|
||||
pass
|
||||
|
||||
|
||||
class GiteaApiError(GiteaError):
|
||||
"""API-related errors."""
|
||||
pass
|
||||
|
||||
|
||||
class GiteaAuthError(GiteaError):
|
||||
"""Authentication-related errors."""
|
||||
pass
|
||||
@@ -0,0 +1,355 @@
|
||||
"""
|
||||
Gitea Package Registry Client
|
||||
|
||||
This module provides functionality to publish Python packages to Gitea's package registry.
|
||||
Gitea supports multiple package registries including PyPI-compatible registries.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional, List, Dict, Any
|
||||
|
||||
from ..base import RegistryInterface
|
||||
from .config import GiteaConfig
|
||||
from .exceptions import GiteaError
|
||||
|
||||
|
||||
class GiteaRegistry(RegistryInterface):
|
||||
"""Client for publishing packages to Gitea package registry."""
|
||||
|
||||
def __init__(self, config: Optional[GiteaConfig] = None):
|
||||
"""Initialize the package registry client.
|
||||
|
||||
Args:
|
||||
config: Gitea configuration. If None, auto-detects from git repository.
|
||||
"""
|
||||
self.config = config or GiteaConfig.from_git_repository()
|
||||
self.config.validate()
|
||||
|
||||
@property
|
||||
def pypi_registry_url(self) -> str:
|
||||
"""Get the PyPI-compatible registry URL for this repository."""
|
||||
return f"{self.config.gitea_url}/api/packages/{self.config.repo_owner}/pypi"
|
||||
|
||||
@property
|
||||
def package_list_url(self) -> str:
|
||||
"""Get the package listing URL for this repository."""
|
||||
return f"{self.config.gitea_url}/api/v1/packages/{self.config.repo_owner}"
|
||||
|
||||
def check_auth(self) -> bool:
|
||||
"""Check if authentication token is available and valid."""
|
||||
if not self.config.auth_token:
|
||||
return False
|
||||
|
||||
try:
|
||||
# Test auth by trying to access packages API
|
||||
import requests
|
||||
headers = {"Authorization": f"token {self.config.auth_token}"}
|
||||
response = requests.get(self.package_list_url, headers=headers, timeout=10)
|
||||
return response.status_code in [200, 404] # 404 is okay if no packages exist yet
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def list_packages(self) -> List[Dict[str, Any]]:
|
||||
"""List all packages for this repository owner.
|
||||
|
||||
Returns:
|
||||
List of package information dictionaries
|
||||
"""
|
||||
try:
|
||||
import requests
|
||||
headers = {}
|
||||
if self.config.auth_token:
|
||||
headers["Authorization"] = f"token {self.config.auth_token}"
|
||||
|
||||
response = requests.get(self.package_list_url, headers=headers, timeout=10)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
except Exception as e:
|
||||
raise GiteaError(f"Failed to list packages: {e}")
|
||||
|
||||
def get_package_info(self, package_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get information about a specific package.
|
||||
|
||||
Args:
|
||||
package_name: Name of the package
|
||||
|
||||
Returns:
|
||||
Package information dictionary or None if not found
|
||||
"""
|
||||
try:
|
||||
packages = self.list_packages()
|
||||
for package in packages:
|
||||
if package.get("name") == package_name:
|
||||
return package
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
def upload_package(self, package_path: Path, dry_run: bool = False) -> bool:
|
||||
"""Upload a package to Gitea registry.
|
||||
|
||||
Args:
|
||||
package_path: Path to package file (.whl or .tar.gz)
|
||||
dry_run: If True, show what would be done without uploading
|
||||
|
||||
Returns:
|
||||
True if upload successful, False otherwise
|
||||
"""
|
||||
if not self.config.auth_token:
|
||||
raise GiteaError("Authentication token required for package upload. Set GITEA_API_TOKEN environment variable.")
|
||||
|
||||
if not package_path.exists():
|
||||
raise GiteaError(f"Package file not found: {package_path}")
|
||||
|
||||
if dry_run:
|
||||
print(f"[DRY RUN] Would upload to: {self.pypi_registry_url}")
|
||||
print(f"[DRY RUN] Would upload: {package_path}")
|
||||
return True
|
||||
|
||||
return self._upload_file(package_path)
|
||||
|
||||
def upload_package_as_release_assets(self,
|
||||
version: str,
|
||||
wheel_path: Path,
|
||||
sdist_path: Optional[Path] = None,
|
||||
dry_run: bool = False) -> bool:
|
||||
"""Upload packages as Gitea release assets (fallback when package registry unavailable).
|
||||
|
||||
Args:
|
||||
version: Version tag (e.g., "v0.8.0")
|
||||
wheel_path: Path to wheel (.whl) file
|
||||
sdist_path: Optional path to source distribution (.tar.gz) file
|
||||
dry_run: If True, show what would be done without uploading
|
||||
|
||||
Returns:
|
||||
True if upload successful, False otherwise
|
||||
"""
|
||||
if not self.config.auth_token:
|
||||
raise GiteaError("Authentication token required for release upload. Set GITEA_API_TOKEN environment variable.")
|
||||
|
||||
if not wheel_path.exists():
|
||||
raise GiteaError(f"Wheel file not found: {wheel_path}")
|
||||
|
||||
if sdist_path and not sdist_path.exists():
|
||||
raise GiteaError(f"Source distribution file not found: {sdist_path}")
|
||||
|
||||
files_to_upload = [wheel_path]
|
||||
if sdist_path:
|
||||
files_to_upload.append(sdist_path)
|
||||
|
||||
if dry_run:
|
||||
print(f"[DRY RUN] Would upload release assets for {version}")
|
||||
print(f"[DRY RUN] Release API: {self.config.repo_api_url}/releases")
|
||||
for file_path in files_to_upload:
|
||||
print(f"[DRY RUN] Would upload: {file_path}")
|
||||
return True
|
||||
|
||||
# Create or get release
|
||||
release_id = self._create_or_get_release(version)
|
||||
if not release_id:
|
||||
return False
|
||||
|
||||
# Upload each file as release asset
|
||||
success = True
|
||||
for file_path in files_to_upload:
|
||||
if not self._upload_release_asset(release_id, file_path):
|
||||
success = False
|
||||
|
||||
return success
|
||||
|
||||
def delete_package_version(self, package_name: str, version: str,
|
||||
dry_run: bool = False) -> bool:
|
||||
"""Delete a specific version of a package.
|
||||
|
||||
Args:
|
||||
package_name: Name of the package
|
||||
version: Version to delete
|
||||
dry_run: If True, show what would be done without deleting
|
||||
|
||||
Returns:
|
||||
True if deletion successful, False otherwise
|
||||
"""
|
||||
if not self.config.auth_token:
|
||||
raise GiteaError("Authentication token required for package deletion.")
|
||||
|
||||
delete_url = f"{self.config.gitea_url}/api/v1/packages/{self.config.repo_owner}/pypi/{package_name}/{version}"
|
||||
|
||||
if dry_run:
|
||||
print(f"[DRY RUN] Would delete: {package_name} v{version}")
|
||||
print(f"[DRY RUN] DELETE {delete_url}")
|
||||
return True
|
||||
|
||||
try:
|
||||
import requests
|
||||
headers = {"Authorization": f"token {self.config.auth_token}"}
|
||||
response = requests.delete(delete_url, headers=headers, timeout=10)
|
||||
|
||||
if response.status_code in [200, 204, 404]: # 404 = already deleted
|
||||
print(f"✅ Deleted: {package_name} v{version}")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ Delete failed: {response.status_code} {response.text}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Delete failed: {e}")
|
||||
return False
|
||||
|
||||
def get_registry_info(self) -> Dict[str, Any]:
|
||||
"""Get information about the package registry configuration.
|
||||
|
||||
Returns:
|
||||
Dictionary with registry information
|
||||
"""
|
||||
return {
|
||||
"gitea_url": self.config.gitea_url,
|
||||
"repo_owner": self.config.repo_owner,
|
||||
"repo_name": self.config.repo_name,
|
||||
"pypi_registry_url": self.pypi_registry_url,
|
||||
"package_list_url": self.package_list_url,
|
||||
"auth_configured": bool(self.config.auth_token),
|
||||
"auth_valid": self.check_auth() if self.config.auth_token else False
|
||||
}
|
||||
|
||||
def _upload_file(self, file_path: Path) -> bool:
|
||||
"""Upload a single file to the registry.
|
||||
|
||||
Args:
|
||||
file_path: Path to file to upload
|
||||
|
||||
Returns:
|
||||
True if upload successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
import requests
|
||||
|
||||
# Gitea PyPI upload API expects PUT with the file content as body
|
||||
# URL format: /api/packages/{owner}/pypi/{filename}
|
||||
upload_url = f"{self.config.gitea_url}/api/packages/{self.config.repo_owner}/pypi"
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
file_content = f.read()
|
||||
|
||||
headers = {
|
||||
'Authorization': f'token {self.config.auth_token}',
|
||||
'Content-Type': 'application/octet-stream'
|
||||
}
|
||||
|
||||
# Upload using PUT request with filename in URL
|
||||
upload_endpoint = f"{upload_url}/{file_path.name}"
|
||||
response = requests.put(
|
||||
upload_endpoint,
|
||||
headers=headers,
|
||||
data=file_content,
|
||||
timeout=60
|
||||
)
|
||||
|
||||
if response.status_code in [200, 201, 409]: # 409 = already exists
|
||||
print(f"✅ Uploaded: {file_path.name}")
|
||||
if response.status_code == 409:
|
||||
print(f" (already exists)")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ Upload failed for {file_path.name}: {response.status_code}")
|
||||
if response.text:
|
||||
print(f" Error: {response.text}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Upload failed for {file_path.name}: {e}")
|
||||
return False
|
||||
|
||||
def _create_or_get_release(self, version: str) -> Optional[int]:
|
||||
"""Create a new release or get existing release ID.
|
||||
|
||||
Args:
|
||||
version: Version tag (e.g., "v0.8.0")
|
||||
|
||||
Returns:
|
||||
Release ID if successful, None otherwise
|
||||
"""
|
||||
try:
|
||||
import requests
|
||||
|
||||
# Ensure version has 'v' prefix
|
||||
tag_name = version if version.startswith('v') else f'v{version}'
|
||||
|
||||
headers = {"Authorization": f"token {self.config.auth_token}"}
|
||||
|
||||
# First, try to get existing release
|
||||
releases_url = f"{self.config.repo_api_url}/releases"
|
||||
response = requests.get(releases_url, headers=headers, timeout=10)
|
||||
|
||||
if response.status_code == 200:
|
||||
releases = response.json()
|
||||
for release in releases:
|
||||
if release.get('tag_name') == tag_name:
|
||||
print(f"✅ Found existing release: {tag_name}")
|
||||
return release['id']
|
||||
|
||||
# Create new release
|
||||
release_data = {
|
||||
"tag_name": tag_name,
|
||||
"name": f"MarkiTect {version.lstrip('v')}",
|
||||
"body": f"Release {version.lstrip('v')}\\n\\nPython packages for MarkiTect.",
|
||||
"draft": False,
|
||||
"prerelease": False
|
||||
}
|
||||
|
||||
response = requests.post(releases_url, headers=headers, json=release_data, timeout=10)
|
||||
|
||||
if response.status_code == 201:
|
||||
release = response.json()
|
||||
print(f"✅ Created release: {tag_name}")
|
||||
return release['id']
|
||||
else:
|
||||
print(f"❌ Failed to create release: {response.status_code} {response.text}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error managing release: {e}")
|
||||
return None
|
||||
|
||||
def _upload_release_asset(self, release_id: int, file_path: Path) -> bool:
|
||||
"""Upload a file as a release asset.
|
||||
|
||||
Args:
|
||||
release_id: Gitea release ID
|
||||
file_path: Path to file to upload
|
||||
|
||||
Returns:
|
||||
True if upload successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
import requests
|
||||
|
||||
# Upload asset to Gitea release
|
||||
upload_url = f"{self.config.repo_api_url}/releases/{release_id}/assets"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"token {self.config.auth_token}"
|
||||
}
|
||||
|
||||
with open(file_path, 'rb') as f:
|
||||
files = {
|
||||
'attachment': (file_path.name, f, 'application/octet-stream')
|
||||
}
|
||||
|
||||
response = requests.post(
|
||||
upload_url,
|
||||
headers=headers,
|
||||
files=files,
|
||||
timeout=120 # Larger timeout for file uploads
|
||||
)
|
||||
|
||||
if response.status_code == 201:
|
||||
print(f"✅ Uploaded release asset: {file_path.name}")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ Failed to upload {file_path.name}: {response.status_code} {response.text}")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Upload failed for {file_path.name}: {e}")
|
||||
return False
|
||||
Reference in New Issue
Block a user