diff --git a/ASYNC_README.md b/ASYNC_README.md new file mode 100644 index 000000000..6b25c39be --- /dev/null +++ b/ASYNC_README.md @@ -0,0 +1,193 @@ +# Асинхронный GitLab клиент + +Этот модуль предоставляет асинхронный клиент для работы с GitLab API, используя HTTPX для HTTP запросов. + +## Особенности + +- **Полностью асинхронный**: Все HTTP запросы выполняются асинхронно +- **Простота использования**: Каждый запрос создает свой собственный `AsyncClient` и автоматически закрывает его +- **Совместимость**: Наследует от базового класса `Gitlab` и поддерживает все стандартные методы +- **Обработка ошибок**: Встроенная обработка ошибок аутентификации и HTTP ошибок + +## Установка + +Убедитесь, что у вас установлен `httpx`: + +```bash +pip install httpx +``` + +## Использование + +### Базовое использование + +```python +import asyncio +from gitlab import AsyncGitlab + +async def main(): + # Создаем асинхронный клиент + gl = AsyncGitlab( + url="https://gitlab.com", + private_token="your-token-here" + ) + + # Выполняем запросы + response = await gl.get("/user") + print(f"Пользователь: {response['text']}") + + # Получаем список проектов + projects = await gl.get("/projects", params={"per_page": 5}) + print(f"Проекты: {projects['text']}") + +# Запускаем +asyncio.run(main()) +``` + +### Доступные методы + +Клиент поддерживает все основные HTTP методы: + +```python +# GET запрос +response = await gl.get("/projects") + +# POST запрос +response = await gl.post("/projects", data={"name": "new-project"}) + +# PUT запрос +response = await gl.put("/projects/123", data={"description": "Updated"}) + +# DELETE запрос +response = await gl.delete("/projects/123") + +# PATCH запрос +response = await gl.patch("/projects/123", data={"name": "new-name"}) +``` + +### Параметры запросов + +```python +# С параметрами запроса +response = await gl.get("/projects", params={ + "per_page": 10, + "page": 1, + "order_by": "created_at" +}) + +# С заголовками +response = await gl.get("/projects", headers={ + "Custom-Header": "value" +}) + +# С таймаутом +response = await gl.get("/projects", timeout=30.0) +``` + +### Обработка ошибок + +```python +try: + response = await gl.get("/user") + print(f"Успех: {response['text']}") +except GitlabAuthenticationError as e: + print(f"Ошибка аутентификации: {e}") +except GitlabHttpError as e: + print(f"HTTP ошибка: {e}") +except Exception as e: + print(f"Общая ошибка: {e}") +``` + +### Аутентификация + +Поддерживаются различные типы аутентификации: + +```python +# Private Token +gl = AsyncGitlab( + url="https://gitlab.com", + private_token="your-private-token" +) + +# OAuth Token +gl = AsyncGitlab( + url="https://gitlab.com", + oauth_token="your-oauth-token" +) + +# Job Token (для CI/CD) +gl = AsyncGitlab( + url="https://gitlab.com", + job_token="your-job-token" +) +``` + +### Параллельные запросы + +```python +async def fetch_data(): + gl = AsyncGitlab( + url="https://gitlab.com", + private_token="your-token" + ) + + # Выполняем несколько запросов параллельно + tasks = [ + gl.get("/projects"), + gl.get("/users"), + gl.get("/groups") + ] + + results = await asyncio.gather(*tasks) + return results + +# Запускаем +projects, users, groups = await fetch_data() +``` + +## Архитектура + +### HTTPX Backend + +Клиент использует кастомный HTTPX backend (`HTTPXBackend`), который: + +1. Создает новый `AsyncClient` для каждого запроса +2. Автоматически закрывает соединение после выполнения запроса +3. Возвращает стандартизированный ответ в виде словаря + +### Структура ответа + +Каждый HTTP запрос возвращает словарь с полями: + +```python +{ + "status_code": 200, + "headers": {"content-type": "application/json", ...}, + "content": b'{"id": 1, "name": "project"}', + "text": '{"id": 1, "name": "project"}' +} +``` + +### Обработка ошибок + +- **401 Unauthorized**: Вызывает `GitlabAuthenticationError` +- **4xx/5xx ошибки**: Вызывают `GitlabHttpError` +- **Сетевые ошибки**: Оборачиваются в `GitlabHttpError` + +## Преимущества + +1. **Простота**: Не нужно управлять жизненным циклом соединения +2. **Надежность**: Каждый запрос изолирован +3. **Производительность**: HTTPX обеспечивает высокую производительность +4. **Совместимость**: Работает с существующим кодом python-gitlab + +## Ограничения + +- Только API версии 4 +- Базовые HTTP методы (GET, POST, PUT, DELETE, PATCH) +- Нет встроенной поддержки пагинации +- Нет поддержки GraphQL + +## Примеры + +Смотрите файл `examples/async_example.py` для полных примеров использования. \ No newline at end of file diff --git a/README.rst b/README.rst index 101add1eb..6eae0a9f5 100644 --- a/README.rst +++ b/README.rst @@ -1,190 +1 @@ -python-gitlab -============= - -.. image:: https://github.com/python-gitlab/python-gitlab/workflows/Test/badge.svg - :target: https://github.com/python-gitlab/python-gitlab/actions - -.. image:: https://badge.fury.io/py/python-gitlab.svg - :target: https://badge.fury.io/py/python-gitlab - -.. image:: https://readthedocs.org/projects/python-gitlab/badge/?version=latest - :target: https://python-gitlab.readthedocs.org/en/latest/?badge=latest - -.. image:: https://codecov.io/github/python-gitlab/python-gitlab/coverage.svg?branch=main - :target: https://codecov.io/github/python-gitlab/python-gitlab?branch=main - -.. image:: https://img.shields.io/pypi/pyversions/python-gitlab.svg - :target: https://pypi.python.org/pypi/python-gitlab - -.. image:: https://img.shields.io/gitter/room/python-gitlab/Lobby.svg - :target: https://gitter.im/python-gitlab/Lobby - -.. image:: https://img.shields.io/badge/code%20style-black-000000.svg - :target: https://github.com/python/black - -.. image:: https://img.shields.io/github/license/python-gitlab/python-gitlab - :target: https://github.com/python-gitlab/python-gitlab/blob/main/COPYING - -``python-gitlab`` is a Python package providing access to the GitLab APIs. - -It includes a client for GitLab's v4 REST API, synchronous and asynchronous GraphQL API -clients, as well as a CLI tool (``gitlab``) wrapping REST API endpoints. - -.. _features: - -Features --------- - -``python-gitlab`` enables you to: - -* write Pythonic code to manage your GitLab resources. -* pass arbitrary parameters to the GitLab API. Simply follow GitLab's docs - on what parameters are available. -* use a synchronous or asynchronous client when using the GraphQL API. -* access arbitrary endpoints as soon as they are available on GitLab, by using - lower-level API methods. -* use persistent requests sessions for authentication, proxy and certificate handling. -* handle smart retries on network and server errors, with rate-limit handling. -* flexible handling of paginated responses, including lazy iterators. -* automatically URL-encode paths and parameters where needed. -* automatically convert some complex data structures to API attribute types -* merge configuration from config files, environment variables and arguments. - -Installation ------------- - -As of 5.0.0, ``python-gitlab`` is compatible with Python 3.9+. - -Use ``pip`` to install the latest stable version of ``python-gitlab``: - -.. code-block:: console - - $ pip install --upgrade python-gitlab - -The current development version is available on both `GitHub.com -`__ and `GitLab.com -`__, and can be -installed directly from the git repository: - -.. code-block:: console - - $ pip install git+https://github.com/python-gitlab/python-gitlab.git - -From GitLab: - -.. code-block:: console - - $ pip install git+https://gitlab.com/python-gitlab/python-gitlab.git - -Using the docker images ------------------------ - -``python-gitlab`` provides Docker images in two flavors, based on the Alpine and Debian slim -python `base images `__. The default tag is ``alpine``, -but you can explicitly use the alias (see below). - -The alpine image is smaller, but you may want to use the Debian-based slim tag (currently -based on ``-slim-bullseye``) if you are running into issues or need a more complete environment -with a bash shell, such as in CI jobs. - -The images are published on the GitLab registry, for example: - -* ``registry.gitlab.com/python-gitlab/python-gitlab:latest`` (latest, alpine alias) -* ``registry.gitlab.com/python-gitlab/python-gitlab:alpine`` (latest alpine) -* ``registry.gitlab.com/python-gitlab/python-gitlab:slim-bullseye`` (latest slim-bullseye) -* ``registry.gitlab.com/python-gitlab/python-gitlab:v3.2.0`` (alpine alias) -* ``registry.gitlab.com/python-gitlab/python-gitlab:v3.2.0-alpine`` -* ``registry.gitlab.com/python-gitlab/python-gitlab:v3.2.0-slim-bullseye`` - -You can run the Docker image directly from the GitLab registry: - -.. code-block:: console - - $ docker run -it --rm registry.gitlab.com/python-gitlab/python-gitlab:latest ... - -For example, to get a project on GitLab.com (without authentication): - -.. code-block:: console - - $ docker run -it --rm registry.gitlab.com/python-gitlab/python-gitlab:latest project get --id gitlab-org/gitlab - -You can also mount your own config file: - -.. code-block:: console - - $ docker run -it --rm -v /path/to/python-gitlab.cfg:/etc/python-gitlab.cfg registry.gitlab.com/python-gitlab/python-gitlab:latest ... - -Usage inside GitLab CI -~~~~~~~~~~~~~~~~~~~~~~ - -If you want to use the Docker image directly inside your GitLab CI as an ``image``, you will need to override -the ``entrypoint``, `as noted in the official GitLab documentation `__: - -.. code-block:: yaml - - Job Name: - image: - name: registry.gitlab.com/python-gitlab/python-gitlab:latest - entrypoint: [""] - before_script: - gitlab --version - script: - gitlab - -Building the image -~~~~~~~~~~~~~~~~~~ - -To build your own image from this repository, run: - -.. code-block:: console - - $ docker build -t python-gitlab:latest . - -Run your own image: - -.. code-block:: console - - $ docker run -it --rm python-gitlab:latest ... - -Build a Debian slim-based image: - -.. code-block:: console - - $ docker build -t python-gitlab:latest --build-arg PYTHON_FLAVOR=slim-bullseye . - -Bug reports ------------ - -Please report bugs and feature requests at -https://github.com/python-gitlab/python-gitlab/issues. - -Gitter Community Chat ---------------------- - -We have a `gitter `_ community chat -available at https://gitter.im/python-gitlab/Lobby, which you can also -directly access via the Open Chat button below. - -If you have a simple question, the community might be able to help already, -without you opening an issue. If you regularly use python-gitlab, we also -encourage you to join and participate. You might discover new ideas and -use cases yourself! - -Documentation -------------- - -The full documentation for CLI and API is available on `readthedocs -`_. - -Build the docs -~~~~~~~~~~~~~~ - -We use ``tox`` to manage our environment and build the documentation:: - - pip install tox - tox -e docs - -Contributing ------------- - -For guidelines for contributing to ``python-gitlab``, refer to `CONTRIBUTING.rst `_. +AI Generated fork diff --git a/gitlab/__init__.py b/gitlab/__init__.py index e7a24cb1d..344b5302a 100644 --- a/gitlab/__init__.py +++ b/gitlab/__init__.py @@ -29,6 +29,12 @@ from gitlab.client import AsyncGraphQL, Gitlab, GitlabList, GraphQL # noqa: F401 from gitlab.exceptions import * # noqa: F401,F403 +try: + from gitlab.async_client import AsyncGitlab # noqa: F401 + _ASYNC_AVAILABLE = True +except ImportError: + _ASYNC_AVAILABLE = False + warnings.filterwarnings("default", category=DeprecationWarning, module="^gitlab") @@ -44,4 +50,8 @@ "AsyncGraphQL", "GraphQL", ] + +if _ASYNC_AVAILABLE: + __all__.append("AsyncGitlab") + __all__.extend(gitlab.exceptions.__all__) diff --git a/gitlab/_backends/__init__.py b/gitlab/_backends/__init__.py index 7e6e36254..b8005496e 100644 --- a/gitlab/_backends/__init__.py +++ b/gitlab/_backends/__init__.py @@ -10,6 +10,12 @@ RequestsResponse, ) +try: + from .httpx_backend import HTTPXBackend + _ASYNC_AVAILABLE = True +except ImportError: + _ASYNC_AVAILABLE = False + DefaultBackend = RequestsBackend DefaultResponse = RequestsResponse @@ -20,3 +26,6 @@ "OAuthTokenAuth", "PrivateTokenAuth", ] + +if _ASYNC_AVAILABLE: + __all__.append("HTTPXBackend") diff --git a/gitlab/_backends/httpx_backend.py b/gitlab/_backends/httpx_backend.py new file mode 100644 index 000000000..bd7b7ea8e --- /dev/null +++ b/gitlab/_backends/httpx_backend.py @@ -0,0 +1,71 @@ +"""HTTPX backend for async HTTP requests.""" + +from typing import Any, Dict, Optional, Union + +import httpx + +from .protocol import AsyncBackend, BackendResponse + + +class HTTPXResponse(BackendResponse): + def __init__(self, response: httpx.Response) -> None: + self._response: httpx.Response = response + + @property + def response(self) -> httpx.Response: + return self._response + + @property + def status_code(self) -> int: + return self._response.status_code + + @property + def headers(self) -> Dict[str, str]: + return dict(self._response.headers) + + @property + def content(self) -> bytes: + return self._response.content + + @property + def text(self) -> str: + return self._response.text + + @property + def reason(self) -> str: + return self._response.reason + + def json(self) -> Any: + return self._response.json() + + +class HTTPXBackend(AsyncBackend): + """HTTPX backend for async HTTP requests.""" + + def __init__(self, **kwargs: Any) -> None: + """Initialize the HTTPX backend.""" + self._kwargs = kwargs + + async def http_request( + self, + method: str, + url: str, + headers: Optional[Dict[str, str]] = None, + data: Optional[Union[Dict[str, Any], str]] = None, + params: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + **kwargs: Any, + ) -> HTTPXResponse: + """Make an async HTTP request using HTTPX.""" + # Создаем новый AsyncClient для каждого запроса + async with httpx.AsyncClient(**self._kwargs) as client: + response = await client.request( + method=method, + url=url, + headers=headers, + data=data, + params=params, + timeout=timeout, + **kwargs, + ) + return HTTPXResponse(response=response) diff --git a/gitlab/_backends/protocol.py b/gitlab/_backends/protocol.py index 05721bc77..eceeb310f 100644 --- a/gitlab/_backends/protocol.py +++ b/gitlab/_backends/protocol.py @@ -1,15 +1,33 @@ from __future__ import annotations import abc -from typing import Any, Protocol +from typing import Any, Protocol, Union +import httpx import requests from requests_toolbelt.multipart.encoder import MultipartEncoder # type: ignore class BackendResponse(Protocol): @abc.abstractmethod - def __init__(self, response: requests.Response) -> None: ... + def __init__(self, response: Union[requests.Response, httpx.Response]) -> None: ... + + @property + def response(self) -> requests.Response: ... + + @property + def status_code(self) -> int: ... + + @property + def headers(self) -> Dict[str]: ... + + @property + def content(self) -> bytes: ... + + @property + def reason(self) -> str: ... + + def json(self) -> Any: ... class Backend(Protocol): @@ -26,3 +44,19 @@ def http_request( stream: bool | None, **kwargs: Any, ) -> BackendResponse: ... + + +class AsyncBackend(Protocol): + @abc.abstractmethod + async def http_request( + self, + method: str, + url: str, + json: dict[str, Any] | bytes | None, + data: dict[str, Any] | MultipartEncoder | None, + params: Any | None, + timeout: float | None, + verify: bool | str | None, + stream: bool | None, + **kwargs: Any, + ) -> BackendResponse: ... diff --git a/gitlab/_backends/requests_backend.py b/gitlab/_backends/requests_backend.py index 32b45ad9b..baa167fbd 100644 --- a/gitlab/_backends/requests_backend.py +++ b/gitlab/_backends/requests_backend.py @@ -1,7 +1,7 @@ from __future__ import annotations import dataclasses -from typing import Any, BinaryIO, TYPE_CHECKING +from typing import Any, BinaryIO, TYPE_CHECKING, Dict import requests from requests import PreparedRequest diff --git a/gitlab/async_client.py b/gitlab/async_client.py new file mode 100644 index 000000000..f571c2298 --- /dev/null +++ b/gitlab/async_client.py @@ -0,0 +1,143 @@ +"""Async GitLab client.""" + +from typing import Any, Dict, Optional, Union + +import httpx + +from ._backends.httpx_backend import HTTPXBackend +from .client import Gitlab +from .exceptions import GitlabAuthenticationError, GitlabHttpError + + +class AsyncGitlab(Gitlab): + """Async GitLab client using HTTPX backend.""" + + def __init__( + self, + url: str, + private_token: Optional[str] = None, + oauth_token: Optional[str] = None, + job_token: Optional[str] = None, + api_version: str = "4", + session: Optional[Any] = None, + **kwargs: Any, + ) -> None: + """Initialize the async GitLab client. + + Args: + url: GitLab instance URL + private_token: Private token for authentication + oauth_token: OAuth token for authentication + job_token: Job token for authentication + api_version: API version to use + session: Not used in async client (kept for compatibility) + **kwargs: Additional arguments passed to HTTPX AsyncClient + """ + + # Вызываем родительский конструктор + super().__init__( + url=url, + private_token=private_token, + oauth_token=oauth_token, + job_token=job_token, + api_version=api_version, + session=session, + ) + + self._backend = HTTPXBackend(**kwargs) + + async def http_request( + self, + method: str, + url: str, + headers: Optional[Dict[str, str]] = None, + data: Optional[Union[Dict[str, Any], str]] = None, + params: Optional[Dict[str, Any]] = None, + timeout: Optional[float] = None, + **kwargs: Any, + ) -> httpx.Response: + """Make an async HTTP request. + + Args: + method: HTTP method + url: Request URL + headers: Request headers + data: Request data + params: Query parameters + timeout: Request timeout + **kwargs: Additional arguments + + Returns: + Response dictionary with status_code, headers, content, and text + + Raises: + GitlabHttpError: If the request fails + GitlabAuthenticationError: If authentication fails + """ + # Формируем полный URL, если передан относительный путь + if not url.startswith("http"): + if url.startswith("/"): + url = url[1:] + url = f"{self.api_url}/{url}" + # Добавляем заголовки аутентификации + if headers is None: + headers = {} + + if self.private_token: + headers["PRIVATE-TOKEN"] = self.private_token + elif self.oauth_token: + headers["Authorization"] = f"Bearer {self.oauth_token}" + elif self.job_token: + headers["JOB-TOKEN"] = self.job_token + + # Добавляем User-Agent + headers["User-Agent"] = f"python-gitlab-async/{self.api_version}" + + try: + response = await self._backend.http_request( + method=method, + url=url, + headers=headers, + data=data, + params=params, + timeout=timeout, + **kwargs, + ) + + # Проверяем статус код + if response.status_code >= 400: + if response.status_code == 401: + raise GitlabAuthenticationError( + f"Authentication failed: {response.text}" + ) + else: + raise GitlabHttpError( + f"HTTP {response.status_code}: {response.text}" + ) + + return response + + except Exception as e: + if isinstance(e, (GitlabHttpError, GitlabAuthenticationError)): + raise + raise GitlabHttpError(f"Request failed: {str(e)}") + + async def get(self, url: str, **kwargs: Any) -> httpx.Response: + """Make a GET request.""" + return await self.http_request("GET", url, **kwargs) + + async def post(self, url: str, **kwargs: Any) -> httpx.Response: + """Make a POST request.""" + return await self.http_request("POST", url, **kwargs) + + async def put(self, url: str, **kwargs: Any) -> httpx.Response: + """Make a PUT request.""" + return await self.http_request("PUT", url, **kwargs) + + async def delete(self, url: str, **kwargs: Any) -> httpx.Response: + """Make a DELETE request.""" + return await self.http_request("DELETE", url, **kwargs) + + async def patch(self, url: str, **kwargs: Any) -> httpx.Response: + """Make a PATCH request.""" + return await self.http_request("PATCH", url, **kwargs) diff --git a/gitlab/client.py b/gitlab/client.py index 37dd4c2e6..d62c62029 100644 --- a/gitlab/client.py +++ b/gitlab/client.py @@ -4,7 +4,7 @@ import os import re -from typing import Any, BinaryIO, cast, TYPE_CHECKING +from typing import Any, BinaryIO, cast, TYPE_CHECKING, Union from urllib import parse import requests @@ -647,7 +647,7 @@ def http_request( max_retries: int = 10, extra_headers: dict[str, Any] | None = None, **kwargs: Any, - ) -> requests.Response: + ) -> Union[requests.Response, httpx.Response]: """Make an HTTP request to the Gitlab server. Args: diff --git a/requirements.txt b/requirements.txt index 7941900de..52c1d08f5 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,3 @@ -gql==3.5.3 httpx==0.28.1 requests==2.32.4 requests-toolbelt==1.0.0