qodev-gitlab-api 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,23 @@
1
+ name: CI
2
+ on:
3
+ push:
4
+ branches: [main]
5
+ pull_request:
6
+
7
+ jobs:
8
+ lint:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: astral-sh/setup-uv@v4
13
+ - run: uv sync --all-extras
14
+ - run: uv run ruff check .
15
+ - run: uv run ruff format --check .
16
+
17
+ test:
18
+ runs-on: ubuntu-latest
19
+ steps:
20
+ - uses: actions/checkout@v4
21
+ - uses: astral-sh/setup-uv@v4
22
+ - run: uv sync --all-extras
23
+ - run: uv run pytest -v
@@ -0,0 +1,16 @@
1
+ name: Publish to PyPI
2
+ on:
3
+ push:
4
+ tags: ["v*"]
5
+
6
+ jobs:
7
+ publish:
8
+ runs-on: ubuntu-latest
9
+ environment: pypi
10
+ permissions:
11
+ id-token: write
12
+ steps:
13
+ - uses: actions/checkout@v4
14
+ - uses: astral-sh/setup-uv@v4
15
+ - run: uv build
16
+ - uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.pyc
3
+ .venv/
4
+ *.egg-info/
5
+ dist/
6
+ build/
7
+ .ruff_cache/
8
+ .pytest_cache/
9
+ .coverage
10
+ htmlcov/
11
+ uv.lock
@@ -0,0 +1,45 @@
1
+ Metadata-Version: 2.4
2
+ Name: qodev-gitlab-api
3
+ Version: 0.1.0
4
+ Summary: Python client for the GitLab API
5
+ Project-URL: Repository, https://github.com/qodevai/gitlab-api
6
+ Project-URL: Issues, https://github.com/qodevai/gitlab-api/issues
7
+ Author-email: Jan Scheffler <jan.scheffler@qodev.ai>
8
+ License: MIT
9
+ Keywords: api,client,gitlab
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.11
19
+ Requires-Dist: httpx>=0.28.1
20
+ Requires-Dist: python-dotenv>=1.1.0
21
+ Provides-Extra: dev
22
+ Requires-Dist: pyright>=1.1.0; extra == 'dev'
23
+ Requires-Dist: pytest-cov>=4.1.0; extra == 'dev'
24
+ Requires-Dist: pytest-mock>=3.12.0; extra == 'dev'
25
+ Requires-Dist: pytest>=8.0.0; extra == 'dev'
26
+ Requires-Dist: ruff>=0.8.0; extra == 'dev'
27
+ Description-Content-Type: text/markdown
28
+
29
+ # qodev-gitlab-api
30
+
31
+ Python client for the GitLab API.
32
+
33
+ ## Installation
34
+
35
+ ```bash
36
+ pip install qodev-gitlab-api
37
+ ```
38
+
39
+ ## Usage
40
+
41
+ ```python
42
+ from qodev_gitlab_api import GitLabClient
43
+
44
+ client = GitLabClient()
45
+ ```
@@ -0,0 +1,17 @@
1
+ # qodev-gitlab-api
2
+
3
+ Python client for the GitLab API.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ pip install qodev-gitlab-api
9
+ ```
10
+
11
+ ## Usage
12
+
13
+ ```python
14
+ from qodev_gitlab_api import GitLabClient
15
+
16
+ client = GitLabClient()
17
+ ```
@@ -0,0 +1,84 @@
1
+ [project]
2
+ name = "qodev-gitlab-api"
3
+ version = "0.1.0"
4
+ description = "Python client for the GitLab API"
5
+ readme = "README.md"
6
+ authors = [
7
+ { name = "Jan Scheffler", email = "jan.scheffler@qodev.ai" }
8
+ ]
9
+ license = { text = "MIT" }
10
+ requires-python = ">=3.11"
11
+ keywords = ["gitlab", "api", "client"]
12
+ classifiers = [
13
+ "Development Status :: 4 - Beta",
14
+ "Intended Audience :: Developers",
15
+ "License :: OSI Approved :: MIT License",
16
+ "Programming Language :: Python :: 3",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3.13",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = [
23
+ "httpx>=0.28.1",
24
+ "python-dotenv>=1.1.0",
25
+ ]
26
+
27
+ [project.urls]
28
+ Repository = "https://github.com/qodevai/gitlab-api"
29
+ Issues = "https://github.com/qodevai/gitlab-api/issues"
30
+
31
+ [project.optional-dependencies]
32
+ dev = [
33
+ "pytest>=8.0.0",
34
+ "pytest-cov>=4.1.0",
35
+ "pytest-mock>=3.12.0",
36
+ "ruff>=0.8.0",
37
+ "pyright>=1.1.0",
38
+ ]
39
+
40
+ [build-system]
41
+ requires = ["hatchling"]
42
+ build-backend = "hatchling.build"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["src/qodev_gitlab_api"]
46
+
47
+ [tool.ruff]
48
+ target-version = "py311"
49
+ line-length = 120
50
+
51
+ [tool.ruff.lint]
52
+ select = [
53
+ "E", # pycodestyle errors
54
+ "F", # pyflakes
55
+ "I", # isort
56
+ "UP", # pyupgrade
57
+ "B", # flake8-bugbear
58
+ "SIM", # flake8-simplify
59
+ "RUF", # ruff-specific rules
60
+ ]
61
+ ignore = [
62
+ "E501", # line too long (handled by formatter)
63
+ ]
64
+
65
+ [tool.ruff.lint.isort]
66
+ known-first-party = ["qodev_gitlab_api"]
67
+
68
+ [tool.ruff.format]
69
+ quote-style = "double"
70
+ indent-style = "space"
71
+
72
+ [tool.pyright]
73
+ pythonVersion = "3.11"
74
+ typeCheckingMode = "basic"
75
+
76
+ [tool.pytest.ini_options]
77
+ testpaths = ["tests"]
78
+
79
+ [tool.typos.default]
80
+ extend-ignore-re = ["[0-9a-zA-Z]{40,}"]
81
+ extend-words = { noteable = "noteable" }
82
+
83
+ [tool.typos.files]
84
+ extend-exclude = ["uv.lock", "*.log"]
@@ -0,0 +1,18 @@
1
+ """GitLab API client library."""
2
+
3
+ from qodev_gitlab_api.client import GitLabClient
4
+ from qodev_gitlab_api.exceptions import APIError, AuthenticationError, ConfigurationError, GitLabError, NotFoundError
5
+ from qodev_gitlab_api.models import DiffPosition, FileFromBase64, FileFromPath, FileSource
6
+
7
+ __all__ = [
8
+ "APIError",
9
+ "AuthenticationError",
10
+ "ConfigurationError",
11
+ "DiffPosition",
12
+ "FileFromBase64",
13
+ "FileFromPath",
14
+ "FileSource",
15
+ "GitLabClient",
16
+ "GitLabError",
17
+ "NotFoundError",
18
+ ]
@@ -0,0 +1,143 @@
1
+ """Base GitLab client mixin with HTTP primitives."""
2
+
3
+ import logging
4
+ import os
5
+ from typing import Any
6
+ from urllib.parse import quote
7
+
8
+ import httpx
9
+ from dotenv import load_dotenv
10
+
11
+ from qodev_gitlab_api.exceptions import APIError, AuthenticationError, ConfigurationError, NotFoundError
12
+
13
+ load_dotenv()
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def _raise_for_status(e: httpx.HTTPStatusError) -> None:
19
+ """Convert httpx HTTP errors into typed exceptions."""
20
+ status = e.response.status_code
21
+ body = e.response.text[:500] if e.response.text else ""
22
+ if status == 401:
23
+ raise AuthenticationError(f"Authentication failed: {body}") from e
24
+ if status == 404:
25
+ raise NotFoundError(f"Not found: {body}", status_code=status) from e
26
+ raise APIError(f"API error {status}: {body}", status_code=status, response_body=body) from e
27
+
28
+
29
+ class BaseClientMixin:
30
+ """Base mixin providing HTTP primitives and initialization."""
31
+
32
+ token: str | None
33
+ base_url: str
34
+ api_url: str
35
+ client: httpx.Client
36
+
37
+ def __init__(self, token: str | None = None, base_url: str | None = None, validate: bool = True):
38
+ self.token = token or os.getenv("GITLAB_TOKEN")
39
+ self.base_url = (
40
+ base_url or os.getenv("GITLAB_BASE_URL") or os.getenv("GITLAB_URL") or "https://gitlab.com"
41
+ ).rstrip("/")
42
+
43
+ self._validate_configuration()
44
+
45
+ self.api_url = f"{self.base_url}/api/v4"
46
+ headers: dict[str, str] = {"PRIVATE-TOKEN": str(self.token), "Content-Type": "application/json"}
47
+ self.client = httpx.Client(
48
+ base_url=self.api_url,
49
+ headers=headers,
50
+ timeout=30.0,
51
+ )
52
+
53
+ if validate:
54
+ self._test_connectivity()
55
+ else:
56
+ logger.info(f"GitLab client initialized for {self.base_url} (validation skipped)")
57
+
58
+ def _validate_configuration(self) -> None:
59
+ if not self.token:
60
+ raise ConfigurationError(
61
+ "GITLAB_TOKEN environment variable is required. Set it in your .env file or environment."
62
+ )
63
+ if not self.base_url.startswith(("http://", "https://")):
64
+ raise ConfigurationError(f"GITLAB_URL must start with http:// or https://, got: {self.base_url}")
65
+
66
+ def _test_connectivity(self) -> None:
67
+ try:
68
+ version_info = self.get("/version")
69
+ logger.info(f"Connected to GitLab {version_info.get('version', 'unknown')} at {self.base_url}")
70
+ except (AuthenticationError, APIError, NotFoundError):
71
+ raise
72
+ except httpx.HTTPStatusError as e:
73
+ _raise_for_status(e)
74
+ except httpx.RequestError as e:
75
+ raise ConfigurationError(f"Cannot connect to GitLab at {self.base_url}. Check your GITLAB_URL.") from e
76
+
77
+ @staticmethod
78
+ def _encode_project_id(project_id: str) -> str:
79
+ return quote(project_id, safe="")
80
+
81
+ def get(self, endpoint: str, params: dict[str, Any] | None = None) -> Any:
82
+ """GET request to GitLab API."""
83
+ try:
84
+ logger.debug(f"GET {endpoint} with params={params}")
85
+ response = self.client.get(endpoint, params=params)
86
+ response.raise_for_status()
87
+ return response.json()
88
+ except httpx.HTTPStatusError as e:
89
+ logger.error(f"GitLab API error for GET {endpoint}: {e.response.status_code}")
90
+ _raise_for_status(e)
91
+ except httpx.RequestError as e:
92
+ logger.error(f"Network error for GET {endpoint}: {e}")
93
+ raise
94
+
95
+ def get_paginated(
96
+ self, endpoint: str, params: dict[str, Any] | None = None, per_page: int = 100, max_pages: int = 100
97
+ ) -> list[Any]:
98
+ """GET request with pagination support."""
99
+ params = params or {}
100
+ params["per_page"] = min(per_page, 100)
101
+ params["page"] = 1
102
+
103
+ all_results: list[Any] = []
104
+ pages_fetched = 0
105
+
106
+ try:
107
+ while pages_fetched < max_pages:
108
+ logger.debug(f"GET {endpoint} page {params['page']} (per_page={params['per_page']})")
109
+ response = self.client.get(endpoint, params=params)
110
+ response.raise_for_status()
111
+ results = response.json()
112
+
113
+ if not results:
114
+ break
115
+
116
+ all_results.extend(results)
117
+ pages_fetched += 1
118
+
119
+ if "x-next-page" not in response.headers or not response.headers["x-next-page"]:
120
+ break
121
+
122
+ params["page"] += 1
123
+
124
+ if pages_fetched >= max_pages:
125
+ logger.warning(f"Hit max_pages limit ({max_pages}) for {endpoint}. Results may be incomplete.")
126
+
127
+ logger.debug(f"Fetched {len(all_results)} results from {pages_fetched} pages for {endpoint}")
128
+ return all_results
129
+
130
+ except httpx.HTTPStatusError as e:
131
+ logger.error(f"GitLab API error during pagination of {endpoint}: {e.response.status_code}")
132
+ _raise_for_status(e)
133
+ return [] # unreachable, for type checker
134
+
135
+ def get_projects(self, owned: bool = False, membership: bool = True) -> list[dict[str, Any]]:
136
+ """Get all projects."""
137
+ params: dict[str, Any] = {"membership": membership, "owned": owned}
138
+ return self.get_paginated("/projects", params=params)
139
+
140
+ def get_project(self, project_id: str) -> dict[str, Any]:
141
+ """Get a specific project by ID or path."""
142
+ encoded_id = self._encode_project_id(project_id)
143
+ return self.get(f"/projects/{encoded_id}")
@@ -0,0 +1,64 @@
1
+ """File operations client mixin."""
2
+
3
+ import base64
4
+ import binascii
5
+ import logging
6
+ import os
7
+ from typing import Any, cast
8
+ from urllib.parse import quote
9
+
10
+ import httpx
11
+
12
+ from qodev_gitlab_api._base import BaseClientMixin, _raise_for_status
13
+ from qodev_gitlab_api.models import FileFromPath, FileSource
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class FilesMixin(BaseClientMixin):
19
+ """Mixin for file operations."""
20
+
21
+ def get_file_content(self, project_id: str, file_path: str, ref: str) -> str:
22
+ """Get raw file content at a specific ref."""
23
+ encoded_id = self._encode_project_id(project_id)
24
+ encoded_path = quote(file_path, safe="")
25
+ try:
26
+ response = self.client.get(
27
+ f"/projects/{encoded_id}/repository/files/{encoded_path}/raw",
28
+ params={"ref": ref},
29
+ )
30
+ response.raise_for_status()
31
+ return response.text
32
+ except httpx.HTTPStatusError as e:
33
+ _raise_for_status(e)
34
+ return ""
35
+
36
+ def upload_file(self, project_id: str, source: FileSource) -> dict[str, Any]:
37
+ """Upload a file to GitLab for use in markdown."""
38
+ encoded_id = self._encode_project_id(project_id)
39
+
40
+ if "path" in source:
41
+ file_path = cast(FileFromPath, source)["path"]
42
+ with open(file_path, "rb") as f:
43
+ file_content = f.read()
44
+ filename = os.path.basename(file_path)
45
+ else:
46
+ try:
47
+ file_content = base64.b64decode(source["base64"], validate=True)
48
+ except binascii.Error as e:
49
+ raise ValueError(f"Invalid base64 data: {e}") from e
50
+ filename = source["filename"]
51
+
52
+ files = {"file": (filename, file_content)}
53
+ try:
54
+ response = httpx.post(
55
+ f"{self.api_url}/projects/{encoded_id}/uploads",
56
+ files=files,
57
+ headers={"PRIVATE-TOKEN": str(self.token)},
58
+ timeout=30.0,
59
+ )
60
+ response.raise_for_status()
61
+ return response.json()
62
+ except httpx.HTTPStatusError as e:
63
+ _raise_for_status(e)
64
+ return {}
@@ -0,0 +1,119 @@
1
+ """Issue client mixin."""
2
+
3
+ import logging
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+ from qodev_gitlab_api._base import BaseClientMixin, _raise_for_status
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class IssuesMixin(BaseClientMixin):
14
+ """Mixin for issue operations."""
15
+
16
+ def get_issues(
17
+ self,
18
+ project_id: str,
19
+ state: str = "opened",
20
+ labels: str | None = None,
21
+ assignee_id: int | None = None,
22
+ milestone: str | None = None,
23
+ per_page: int = 20,
24
+ max_pages: int = 10,
25
+ ) -> list[dict[str, Any]]:
26
+ encoded_id = self._encode_project_id(project_id)
27
+ params: dict[str, Any] = {"state": state}
28
+ if labels:
29
+ params["labels"] = labels
30
+ if assignee_id is not None:
31
+ params["assignee_id"] = assignee_id
32
+ if milestone:
33
+ params["milestone"] = milestone
34
+ return self.get_paginated(
35
+ f"/projects/{encoded_id}/issues", params=params, per_page=per_page, max_pages=max_pages
36
+ )
37
+
38
+ def get_issue(self, project_id: str, issue_iid: int) -> dict[str, Any]:
39
+ encoded_id = self._encode_project_id(project_id)
40
+ return self.get(f"/projects/{encoded_id}/issues/{issue_iid}")
41
+
42
+ def create_issue(
43
+ self,
44
+ project_id: str,
45
+ title: str,
46
+ description: str | None = None,
47
+ labels: str | None = None,
48
+ assignee_ids: list[int] | None = None,
49
+ milestone_id: int | None = None,
50
+ ) -> dict[str, Any]:
51
+ encoded_id = self._encode_project_id(project_id)
52
+ data: dict[str, Any] = {"title": title}
53
+ if description:
54
+ data["description"] = description
55
+ if labels:
56
+ data["labels"] = labels
57
+ if assignee_ids:
58
+ data["assignee_ids"] = assignee_ids
59
+ if milestone_id is not None:
60
+ data["milestone_id"] = milestone_id
61
+
62
+ try:
63
+ response = self.client.post(f"/projects/{encoded_id}/issues", json=data)
64
+ response.raise_for_status()
65
+ return response.json()
66
+ except httpx.HTTPStatusError as e:
67
+ _raise_for_status(e)
68
+ return {}
69
+
70
+ def update_issue(
71
+ self,
72
+ project_id: str,
73
+ issue_iid: int,
74
+ title: str | None = None,
75
+ description: str | None = None,
76
+ state_event: str | None = None,
77
+ labels: str | None = None,
78
+ assignee_ids: list[int] | None = None,
79
+ milestone_id: int | None = None,
80
+ ) -> dict[str, Any]:
81
+ encoded_id = self._encode_project_id(project_id)
82
+ data: dict[str, Any] = {}
83
+ if title:
84
+ data["title"] = title
85
+ if description is not None:
86
+ data["description"] = description
87
+ if state_event:
88
+ data["state_event"] = state_event
89
+ if labels is not None:
90
+ data["labels"] = labels
91
+ if assignee_ids is not None:
92
+ data["assignee_ids"] = assignee_ids
93
+ if milestone_id is not None:
94
+ data["milestone_id"] = milestone_id
95
+
96
+ try:
97
+ response = self.client.put(f"/projects/{encoded_id}/issues/{issue_iid}", json=data)
98
+ response.raise_for_status()
99
+ return response.json()
100
+ except httpx.HTTPStatusError as e:
101
+ _raise_for_status(e)
102
+ return {}
103
+
104
+ def close_issue(self, project_id: str, issue_iid: int) -> dict[str, Any]:
105
+ return self.update_issue(project_id, issue_iid, state_event="close")
106
+
107
+ def get_issue_notes(self, project_id: str, issue_iid: int) -> list[dict[str, Any]]:
108
+ encoded_id = self._encode_project_id(project_id)
109
+ return self.get_paginated(f"/projects/{encoded_id}/issues/{issue_iid}/notes")
110
+
111
+ def create_issue_note(self, project_id: str, issue_iid: int, body: str) -> dict[str, Any]:
112
+ encoded_id = self._encode_project_id(project_id)
113
+ try:
114
+ response = self.client.post(f"/projects/{encoded_id}/issues/{issue_iid}/notes", json={"body": body})
115
+ response.raise_for_status()
116
+ return response.json()
117
+ except httpx.HTTPStatusError as e:
118
+ _raise_for_status(e)
119
+ return {}