""" 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