Spaces:
Sleeping
Sleeping
| import logging | |
| import base64 | |
| import asyncio | |
| import httpx | |
| from typing import Tuple, Optional | |
| # Настройка локального логгера для слоя GitHub | |
| logger = logging.getLogger("CyberAgentGitHub") | |
| class GitHubClient: | |
| def __init__(self, token: str, repo: str, branch: str = "main"): | |
| """ | |
| Асинхронный клиент для безопасного взаимодействия с GitHub REST API | |
| с поддержкой пула соединений httpx. | |
| """ | |
| self.token = token | |
| self.repo = repo | |
| self.branch = branch | |
| # Стабильная версия API GitHub и авторизация | |
| self.headers = { | |
| "Authorization": f"Bearer {token}" if token else "", | |
| "Accept": "application/vnd.github+json", | |
| "X-GitHub-Api-Version": "2022-11-28" | |
| } | |
| # Внутренний асинхронный клиент | |
| self._client: Optional[httpx.AsyncClient] = None | |
| def _get_client(self) -> httpx.AsyncClient: | |
| """ | |
| Ленивая инициализация HTTP-клиента для предотвращения | |
| конфликтов с Event Loop при импорте модуля. | |
| """ | |
| if self._client is None or self._client.is_closed: | |
| self._client = httpx.AsyncClient(headers=self.headers, timeout=15.0) | |
| return self._client | |
| async def close(self): | |
| """Корректная утилизация сетевых ресурсов пула""" | |
| if self._client and not self._client.is_closed: | |
| await self._client.aclose() | |
| logger.info("Пул соединений с GitHub успешно закрыт.") | |
| async def get_file(self, target_path: str) -> Tuple[Optional[str], Optional[str]]: | |
| """ | |
| Асинхронно возвращает кортеж (контент, sha). | |
| Если файл отсутствует (404), возвращает ("", None). | |
| В случае критического сбоя сети возвращает (None, None). | |
| """ | |
| if not self.token: | |
| logger.error("Критическая ошибка: GitHub Token не задан.") | |
| return None, None | |
| url = f"https://api.github.com/repos/{self.repo}/contents/{target_path}" | |
| params = {"ref": self.branch} | |
| client = self._get_client() | |
| for attempt in range(3): | |
| try: | |
| res = await client.get(url, params=params) | |
| if res.status_code == 200: | |
| data = res.json() | |
| if "content" not in data: | |
| logger.error(f"Указанный путь {target_path} не содержит контента (возможно, это директория).") | |
| return None, None | |
| # Очистка контента от переносов строк, которые генерирует GitHub для Base64 | |
| raw_b64 = data["content"].replace("\n", "").replace("\r", "") | |
| decoded_content = base64.b64decode(raw_b64).decode("utf-8") | |
| return decoded_content, data["sha"] | |
| elif res.status_code == 404: | |
| logger.info(f"Файл {target_path} не найден в ветке {self.branch}. Он будет создан.") | |
| return "", None | |
| else: | |
| logger.warning(f"[Попытка {attempt+1}/3] Неожиданный ответ GitHub API [Get]: {res.status_code}") | |
| except (httpx.HTTPError, httpx.RequestError) as e: | |
| logger.warning(f"[Попытка {attempt+1}/3] Сетевой сбой при обращении к репозиторию: {str(e)}") | |
| if attempt < 2: | |
| await asyncio.sleep(0.5 * (attempt + 1)) | |
| return None, None | |
| async def commit_file(self, target_path: str, new_content: str, message: str) -> bool: | |
| """ | |
| Безопасно создает или атомарно обновляет файл в репозитории. | |
| Исключает перезапись данных при нарушении синхронизации (Race Conditions). | |
| """ | |
| # Важно: вызываем асинхронную версию get_file через await | |
| current_content, sha = await self.get_file(target_path) | |
| # Защитный барьер: прерываем коммит, если состояние ветки неизвестно | |
| if current_content is None and sha is None: | |
| logger.error(f"Отмена фиксации коммита для {target_path}: целостность ветки под вопросом.") | |
| return False | |
| # Интеллектуальная оптимизация: не тратим лимиты запросов, если код идентичен | |
| if current_content == new_content and sha is not None: | |
| logger.info(f"Изменения в локальной матрице файла {target_path} отсутствуют. Коммит отменен.") | |
| return True | |
| url = f"https://api.github.com/repos/{self.repo}/contents/{target_path}" | |
| payload = { | |
| "message": message, | |
| "content": base64.b64encode(new_content.encode("utf-8")).decode("utf-8"), | |
| "branch": self.branch | |
| } | |
| if sha: | |
| payload["sha"] = sha | |
| client = self._get_client() | |
| for attempt in range(3): | |
| try: | |
| res = await client.put(url, json=payload) | |
| if res.status_code in [200, 201]: | |
| logger.info(f"Файл {target_path} успешно закоммичен в GitHub репозиторий.") | |
| return True | |
| else: | |
| logger.warning(f"[Попытка {attempt+1}/3] Ошибка фиксации коммита [Put]: {res.status_code} — {res.text}") | |
| except (httpx.HTTPError, httpx.RequestError) as e: | |
| logger.warning(f"[Попытка {attempt+1}/3] Исключение сети при отправке коммита: {str(e)}") | |
| if attempt < 2: | |
| await asyncio.sleep(0.5 * (attempt + 1)) | |
| logger.error(f"Критический сбой: не удалось синхронизировать {target_path} после 3 попыток.") | |
| return False | |