forgejo 1.0.0__py3-none-any.whl
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.
- forgejo/__init__.py +33 -0
- forgejo/client.py +242 -0
- forgejo/constants.py +46 -0
- forgejo/exceptions.py +31 -0
- forgejo/models.py +208 -0
- forgejo/py.typed +0 -0
- forgejo-1.0.0.dist-info/METADATA +147 -0
- forgejo-1.0.0.dist-info/RECORD +11 -0
- forgejo-1.0.0.dist-info/WHEEL +5 -0
- forgejo-1.0.0.dist-info/licenses/LICENSE +21 -0
- forgejo-1.0.0.dist-info/top_level.txt +1 -0
forgejo/__init__.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Async client library for the Forgejo (and Gitea) REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .client import ForgejoClient
|
|
6
|
+
from .constants import TERMINAL_RUN_STATUSES
|
|
7
|
+
from .exceptions import (
|
|
8
|
+
ForgejoAuthenticationError,
|
|
9
|
+
ForgejoConnectionError,
|
|
10
|
+
ForgejoError,
|
|
11
|
+
ForgejoNotFoundError,
|
|
12
|
+
ForgejoResponseError,
|
|
13
|
+
)
|
|
14
|
+
from .models import Commit, Release, Repository, ServerInfo, User, WorkflowRun
|
|
15
|
+
|
|
16
|
+
__version__ = "1.0.0"
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"TERMINAL_RUN_STATUSES",
|
|
20
|
+
"Commit",
|
|
21
|
+
"ForgejoAuthenticationError",
|
|
22
|
+
"ForgejoClient",
|
|
23
|
+
"ForgejoConnectionError",
|
|
24
|
+
"ForgejoError",
|
|
25
|
+
"ForgejoNotFoundError",
|
|
26
|
+
"ForgejoResponseError",
|
|
27
|
+
"Release",
|
|
28
|
+
"Repository",
|
|
29
|
+
"ServerInfo",
|
|
30
|
+
"User",
|
|
31
|
+
"WorkflowRun",
|
|
32
|
+
"__version__",
|
|
33
|
+
]
|
forgejo/client.py
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Async client for the Forgejo (and Gitea) REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import socket
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from types import TracebackType
|
|
9
|
+
from typing import Any, Self
|
|
10
|
+
|
|
11
|
+
import aiohttp
|
|
12
|
+
from yarl import URL
|
|
13
|
+
|
|
14
|
+
from .constants import (
|
|
15
|
+
API_PREFIX,
|
|
16
|
+
COUNT_HEADER,
|
|
17
|
+
DEFAULT_TIMEOUT,
|
|
18
|
+
EP_ISSUE_SEARCH,
|
|
19
|
+
EP_NEW_NOTIFICATIONS,
|
|
20
|
+
EP_REPO,
|
|
21
|
+
EP_REPO_COMMITS,
|
|
22
|
+
EP_REPO_RELEASES,
|
|
23
|
+
EP_REPO_TASKS,
|
|
24
|
+
EP_USER,
|
|
25
|
+
EP_USER_REPOS,
|
|
26
|
+
EP_VERSION,
|
|
27
|
+
REPO_PAGE_SIZE,
|
|
28
|
+
)
|
|
29
|
+
from .exceptions import (
|
|
30
|
+
ForgejoAuthenticationError,
|
|
31
|
+
ForgejoConnectionError,
|
|
32
|
+
ForgejoNotFoundError,
|
|
33
|
+
ForgejoResponseError,
|
|
34
|
+
)
|
|
35
|
+
from .models import Commit, Release, Repository, ServerInfo, User, WorkflowRun
|
|
36
|
+
|
|
37
|
+
__all__ = ["ForgejoClient"]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class ForgejoClient:
|
|
41
|
+
"""Talk to one Forgejo instance.
|
|
42
|
+
|
|
43
|
+
The client is safe to reuse across many requests and holds no state beyond
|
|
44
|
+
the session and credentials.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
def __init__(
|
|
48
|
+
self,
|
|
49
|
+
base_url: str,
|
|
50
|
+
token: str | None = None,
|
|
51
|
+
*,
|
|
52
|
+
session: aiohttp.ClientSession | None = None,
|
|
53
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
54
|
+
verify_ssl: bool = True,
|
|
55
|
+
) -> None:
|
|
56
|
+
"""Configure the client.
|
|
57
|
+
|
|
58
|
+
Pass ``session`` to reuse an existing one; the caller keeps ownership of
|
|
59
|
+
it and ``close()`` will leave it open.
|
|
60
|
+
"""
|
|
61
|
+
self._base = URL(base_url.rstrip("/") + API_PREFIX + "/")
|
|
62
|
+
self._token = token
|
|
63
|
+
self._session = session
|
|
64
|
+
self._owns_session = session is None
|
|
65
|
+
self._timeout = aiohttp.ClientTimeout(total=timeout)
|
|
66
|
+
self._verify_ssl = verify_ssl
|
|
67
|
+
|
|
68
|
+
async def __aenter__(self) -> Self:
|
|
69
|
+
"""Enter the context manager."""
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
async def __aexit__(
|
|
73
|
+
self,
|
|
74
|
+
exc_type: type[BaseException] | None,
|
|
75
|
+
exc: BaseException | None,
|
|
76
|
+
tb: TracebackType | None,
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Leave the context manager, closing an owned session."""
|
|
79
|
+
await self.close()
|
|
80
|
+
|
|
81
|
+
async def close(self) -> None:
|
|
82
|
+
"""Close the session, but only if this client created it."""
|
|
83
|
+
if self._owns_session and self._session is not None:
|
|
84
|
+
await self._session.close()
|
|
85
|
+
self._session = None
|
|
86
|
+
|
|
87
|
+
async def _request(self, path: str, **params: Any) -> Any:
|
|
88
|
+
"""Perform one GET and return decoded JSON."""
|
|
89
|
+
payload, _ = await self._request_with_headers(path, **params)
|
|
90
|
+
return payload
|
|
91
|
+
|
|
92
|
+
async def _request_with_headers(
|
|
93
|
+
self, path: str, **params: Any
|
|
94
|
+
) -> tuple[Any, Mapping[str, str]]:
|
|
95
|
+
"""Perform one GET and return decoded JSON plus the response headers."""
|
|
96
|
+
if self._session is None:
|
|
97
|
+
self._session = aiohttp.ClientSession()
|
|
98
|
+
self._owns_session = True
|
|
99
|
+
|
|
100
|
+
headers = {"Accept": "application/json"}
|
|
101
|
+
if self._token:
|
|
102
|
+
headers["Authorization"] = f"token {self._token}"
|
|
103
|
+
|
|
104
|
+
url = self._base.join(URL(path.lstrip("/")))
|
|
105
|
+
try:
|
|
106
|
+
response = await self._session.get(
|
|
107
|
+
url,
|
|
108
|
+
headers=headers,
|
|
109
|
+
params={k: str(v) for k, v in params.items() if v is not None},
|
|
110
|
+
timeout=self._timeout,
|
|
111
|
+
ssl=self._verify_ssl,
|
|
112
|
+
)
|
|
113
|
+
except asyncio.TimeoutError as err:
|
|
114
|
+
raise ForgejoConnectionError(f"Timeout talking to {url.host}") from err
|
|
115
|
+
except (aiohttp.ClientError, socket.gaierror) as err:
|
|
116
|
+
raise ForgejoConnectionError(f"Cannot reach {url.host}: {err}") from err
|
|
117
|
+
|
|
118
|
+
async with response:
|
|
119
|
+
if response.status in (401, 403):
|
|
120
|
+
raise ForgejoAuthenticationError(
|
|
121
|
+
"Forgejo rejected the token; check that it is valid and has "
|
|
122
|
+
"the required read scopes"
|
|
123
|
+
)
|
|
124
|
+
if response.status == 404:
|
|
125
|
+
raise ForgejoNotFoundError(f"Not found: {path}")
|
|
126
|
+
if response.status >= 400:
|
|
127
|
+
raise ForgejoResponseError(
|
|
128
|
+
f"Forgejo returned HTTP {response.status} for {path}"
|
|
129
|
+
)
|
|
130
|
+
# A reverse proxy that intercepts the request (an auth portal, a
|
|
131
|
+
# captive login page) answers 200 with HTML. Decoding rather than
|
|
132
|
+
# trusting Content-Type keeps the error message honest.
|
|
133
|
+
try:
|
|
134
|
+
return await response.json(content_type=None), response.headers
|
|
135
|
+
except ValueError as err:
|
|
136
|
+
raise ForgejoResponseError(
|
|
137
|
+
f"Expected JSON from {path}; got something else. Is this URL "
|
|
138
|
+
"really a Forgejo instance, and is it behind an auth proxy?"
|
|
139
|
+
) from err
|
|
140
|
+
|
|
141
|
+
async def get_version(self) -> ServerInfo:
|
|
142
|
+
"""Read the instance version. Works without a token on most installs."""
|
|
143
|
+
data = await self._request(EP_VERSION)
|
|
144
|
+
if not isinstance(data, dict) or "version" not in data:
|
|
145
|
+
raise ForgejoResponseError("No version in response; not a Forgejo API")
|
|
146
|
+
return ServerInfo.from_api(data)
|
|
147
|
+
|
|
148
|
+
async def get_authenticated_user(self) -> User:
|
|
149
|
+
"""Read the account the token belongs to."""
|
|
150
|
+
data = await self._request(EP_USER)
|
|
151
|
+
if not isinstance(data, dict):
|
|
152
|
+
raise ForgejoResponseError("Unexpected payload for the current user")
|
|
153
|
+
return User.from_api(data)
|
|
154
|
+
|
|
155
|
+
async def get_new_notification_count(self) -> int:
|
|
156
|
+
"""Count unread notifications for the authenticated user."""
|
|
157
|
+
data = await self._request(EP_NEW_NOTIFICATIONS)
|
|
158
|
+
if not isinstance(data, dict):
|
|
159
|
+
raise ForgejoResponseError("Unexpected payload for notifications")
|
|
160
|
+
return int(data.get("new", 0))
|
|
161
|
+
|
|
162
|
+
async def _count(self, path: str, **params: Any) -> int:
|
|
163
|
+
"""Ask for a single item and read the total out of the count header.
|
|
164
|
+
|
|
165
|
+
Forgejo reports the size of the full result set in ``X-Total-Count``,
|
|
166
|
+
so paging through everything just to count it would be wasted traffic.
|
|
167
|
+
"""
|
|
168
|
+
_, headers = await self._request_with_headers(path, limit=1, **params)
|
|
169
|
+
try:
|
|
170
|
+
return int(headers.get(COUNT_HEADER, 0))
|
|
171
|
+
except (TypeError, ValueError):
|
|
172
|
+
raise ForgejoResponseError(f"No usable {COUNT_HEADER} for {path}") from None
|
|
173
|
+
|
|
174
|
+
async def get_assigned_issue_count(self) -> int:
|
|
175
|
+
"""Count open issues assigned to the authenticated user."""
|
|
176
|
+
return await self._count(EP_ISSUE_SEARCH, state="open", assigned="true")
|
|
177
|
+
|
|
178
|
+
async def get_review_request_count(self) -> int:
|
|
179
|
+
"""Count open pull requests waiting on the user's review."""
|
|
180
|
+
return await self._count(
|
|
181
|
+
EP_ISSUE_SEARCH, state="open", type="pulls", review_requested="true"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
async def get_latest_release(self, owner: str, repo: str) -> Release | None:
|
|
185
|
+
"""Return the newest release, or ``None`` if the repo has none.
|
|
186
|
+
|
|
187
|
+
``/releases/latest`` answers 404 for a repository without releases, so
|
|
188
|
+
the list endpoint is used instead: an empty list is a state, not an
|
|
189
|
+
error.
|
|
190
|
+
"""
|
|
191
|
+
data = await self._request(
|
|
192
|
+
EP_REPO_RELEASES.format(owner=owner, repo=repo), limit=1
|
|
193
|
+
)
|
|
194
|
+
if not isinstance(data, list) or not data or not isinstance(data[0], dict):
|
|
195
|
+
return None
|
|
196
|
+
return Release.from_api(data[0])
|
|
197
|
+
|
|
198
|
+
async def list_repositories(self, limit: int = REPO_PAGE_SIZE) -> list[Repository]:
|
|
199
|
+
"""List repositories the token can see, newest activity first."""
|
|
200
|
+
data = await self._request(EP_USER_REPOS, limit=limit)
|
|
201
|
+
if not isinstance(data, list):
|
|
202
|
+
raise ForgejoResponseError("Unexpected payload for the repository list")
|
|
203
|
+
return [Repository.from_api(item) for item in data if isinstance(item, dict)]
|
|
204
|
+
|
|
205
|
+
async def get_repository(self, owner: str, repo: str) -> Repository:
|
|
206
|
+
"""Read one repository and its counters."""
|
|
207
|
+
data = await self._request(EP_REPO.format(owner=owner, repo=repo))
|
|
208
|
+
if not isinstance(data, dict):
|
|
209
|
+
raise ForgejoResponseError(f"Unexpected payload for {owner}/{repo}")
|
|
210
|
+
return Repository.from_api(data)
|
|
211
|
+
|
|
212
|
+
async def get_latest_workflow_run(
|
|
213
|
+
self, owner: str, repo: str
|
|
214
|
+
) -> WorkflowRun | None:
|
|
215
|
+
"""Return the most recent Actions run, or ``None`` if there are none.
|
|
216
|
+
|
|
217
|
+
No runs is a legitimate answer for a repository without workflows, so
|
|
218
|
+
it is not an error.
|
|
219
|
+
"""
|
|
220
|
+
data = await self._request(
|
|
221
|
+
EP_REPO_TASKS.format(owner=owner, repo=repo), limit=1
|
|
222
|
+
)
|
|
223
|
+
if not isinstance(data, dict):
|
|
224
|
+
raise ForgejoResponseError(f"Unexpected payload for {owner}/{repo} runs")
|
|
225
|
+
runs = data.get("workflow_runs") or []
|
|
226
|
+
if not runs or not isinstance(runs[0], dict):
|
|
227
|
+
return None
|
|
228
|
+
return WorkflowRun.from_api(runs[0])
|
|
229
|
+
|
|
230
|
+
async def get_latest_commit(self, owner: str, repo: str) -> Commit | None:
|
|
231
|
+
"""Return the tip commit of the default branch, if the repo has one."""
|
|
232
|
+
try:
|
|
233
|
+
data = await self._request(
|
|
234
|
+
EP_REPO_COMMITS.format(owner=owner, repo=repo), limit=1, stat="false"
|
|
235
|
+
)
|
|
236
|
+
except ForgejoNotFoundError:
|
|
237
|
+
# An empty repository has no commits and answers 404 here. That is
|
|
238
|
+
# a state, not a failure.
|
|
239
|
+
return None
|
|
240
|
+
if not isinstance(data, list) or not data or not isinstance(data[0], dict):
|
|
241
|
+
return None
|
|
242
|
+
return Commit.from_api(data[0])
|
forgejo/constants.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Endpoints and magic values for the Forgejo/Gitea REST API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Final
|
|
6
|
+
|
|
7
|
+
API_PREFIX: Final = "/api/v1"
|
|
8
|
+
|
|
9
|
+
DEFAULT_TIMEOUT: Final = 10.0
|
|
10
|
+
|
|
11
|
+
# The version endpoint is the only one that answers without a token on a
|
|
12
|
+
# default install, which makes it the cheapest way to prove a URL points at a
|
|
13
|
+
# Forgejo instance before we ask the user for credentials.
|
|
14
|
+
EP_VERSION: Final = "/version"
|
|
15
|
+
EP_USER: Final = "/user"
|
|
16
|
+
EP_USER_REPOS: Final = "/user/repos"
|
|
17
|
+
EP_NEW_NOTIFICATIONS: Final = "/notifications/new"
|
|
18
|
+
EP_REPO: Final = "/repos/{owner}/{repo}"
|
|
19
|
+
EP_REPO_TASKS: Final = "/repos/{owner}/{repo}/actions/tasks"
|
|
20
|
+
EP_REPO_COMMITS: Final = "/repos/{owner}/{repo}/commits"
|
|
21
|
+
EP_REPO_RELEASES: Final = "/repos/{owner}/{repo}/releases"
|
|
22
|
+
EP_ISSUE_SEARCH: Final = "/repos/issues/search"
|
|
23
|
+
|
|
24
|
+
# Counting endpoints report the total in a header, so asking for a single item
|
|
25
|
+
# is enough to learn how many there are.
|
|
26
|
+
COUNT_HEADER: Final = "X-Total-Count"
|
|
27
|
+
|
|
28
|
+
# Forgejo pages repository listings; 50 is the server-side default maximum on
|
|
29
|
+
# most instances and keeps the picker in a config flow to one round trip.
|
|
30
|
+
REPO_PAGE_SIZE: Final = 50
|
|
31
|
+
|
|
32
|
+
# Terminal states reported by the Actions API. Anything else means the run is
|
|
33
|
+
# still going, which is not the same thing as a run that succeeded.
|
|
34
|
+
RUN_STATUS_SUCCESS: Final = "success"
|
|
35
|
+
RUN_STATUS_FAILURE: Final = "failure"
|
|
36
|
+
RUN_STATUS_CANCELLED: Final = "cancelled"
|
|
37
|
+
RUN_STATUS_SKIPPED: Final = "skipped"
|
|
38
|
+
|
|
39
|
+
TERMINAL_RUN_STATUSES: Final = frozenset(
|
|
40
|
+
{
|
|
41
|
+
RUN_STATUS_SUCCESS,
|
|
42
|
+
RUN_STATUS_FAILURE,
|
|
43
|
+
RUN_STATUS_CANCELLED,
|
|
44
|
+
RUN_STATUS_SKIPPED,
|
|
45
|
+
}
|
|
46
|
+
)
|
forgejo/exceptions.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Exceptions raised by the Forgejo client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"ForgejoAuthenticationError",
|
|
7
|
+
"ForgejoConnectionError",
|
|
8
|
+
"ForgejoError",
|
|
9
|
+
"ForgejoNotFoundError",
|
|
10
|
+
"ForgejoResponseError",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ForgejoError(Exception):
|
|
15
|
+
"""Base class for every error raised by this library."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ForgejoConnectionError(ForgejoError):
|
|
19
|
+
"""The server could not be reached, or it did not answer in time."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ForgejoAuthenticationError(ForgejoError):
|
|
23
|
+
"""The token was rejected, or it lacks the scope for this request."""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ForgejoNotFoundError(ForgejoError):
|
|
27
|
+
"""The requested repository, user or endpoint does not exist."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ForgejoResponseError(ForgejoError):
|
|
31
|
+
"""The server answered, but not with something we can use."""
|
forgejo/models.py
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
"""Typed models returned by the client.
|
|
2
|
+
|
|
3
|
+
Callers never see raw JSON. Every ``from_api`` classmethod is defensive about
|
|
4
|
+
missing keys: Forgejo has been through several API generations and a field that
|
|
5
|
+
exists on one instance may be absent on an older or newer one. A missing value
|
|
6
|
+
becomes ``None`` rather than a guess.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from datetime import datetime
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
__all__ = ["Commit", "Release", "Repository", "ServerInfo", "User", "WorkflowRun"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _parse_timestamp(value: Any) -> datetime | None:
|
|
19
|
+
"""Parse an API timestamp, tolerating the ``Z`` suffix and junk."""
|
|
20
|
+
if not isinstance(value, str) or not value:
|
|
21
|
+
return None
|
|
22
|
+
try:
|
|
23
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
24
|
+
except ValueError:
|
|
25
|
+
return None
|
|
26
|
+
# Forgejo returns the zero time for "never happened"; surfacing year 1 as a
|
|
27
|
+
# real timestamp makes template sensors render nonsense dates.
|
|
28
|
+
return None if parsed.year <= 1 else parsed
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class ServerInfo:
|
|
33
|
+
"""Version banner of the instance."""
|
|
34
|
+
|
|
35
|
+
version: str
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_api(cls, data: dict[str, Any]) -> ServerInfo:
|
|
39
|
+
"""Build from a ``/version`` payload."""
|
|
40
|
+
return cls(version=str(data.get("version", "")))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass(frozen=True, slots=True)
|
|
44
|
+
class User:
|
|
45
|
+
"""The account the token belongs to."""
|
|
46
|
+
|
|
47
|
+
id: int
|
|
48
|
+
login: str
|
|
49
|
+
full_name: str | None
|
|
50
|
+
is_admin: bool
|
|
51
|
+
|
|
52
|
+
@classmethod
|
|
53
|
+
def from_api(cls, data: dict[str, Any]) -> User:
|
|
54
|
+
"""Build from a ``/user`` payload."""
|
|
55
|
+
return cls(
|
|
56
|
+
id=int(data.get("id", 0)),
|
|
57
|
+
login=str(data.get("login", "")),
|
|
58
|
+
full_name=data.get("full_name") or None,
|
|
59
|
+
is_admin=bool(data.get("is_admin", False)),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True, slots=True)
|
|
64
|
+
class Repository:
|
|
65
|
+
"""A single repository and its counters."""
|
|
66
|
+
|
|
67
|
+
id: int
|
|
68
|
+
name: str
|
|
69
|
+
owner: str
|
|
70
|
+
full_name: str
|
|
71
|
+
private: bool
|
|
72
|
+
archived: bool
|
|
73
|
+
fork: bool
|
|
74
|
+
mirror: bool
|
|
75
|
+
default_branch: str | None
|
|
76
|
+
description: str | None
|
|
77
|
+
language: str | None
|
|
78
|
+
html_url: str | None
|
|
79
|
+
has_actions: bool
|
|
80
|
+
open_issues: int
|
|
81
|
+
open_pull_requests: int
|
|
82
|
+
stars: int
|
|
83
|
+
forks: int
|
|
84
|
+
watchers: int
|
|
85
|
+
releases: int
|
|
86
|
+
size_kb: int
|
|
87
|
+
updated_at: datetime | None
|
|
88
|
+
|
|
89
|
+
@classmethod
|
|
90
|
+
def from_api(cls, data: dict[str, Any]) -> Repository:
|
|
91
|
+
"""Build from a ``/repos/{owner}/{repo}`` payload."""
|
|
92
|
+
owner = data.get("owner") or {}
|
|
93
|
+
return cls(
|
|
94
|
+
id=int(data.get("id", 0)),
|
|
95
|
+
name=str(data.get("name", "")),
|
|
96
|
+
owner=str(owner.get("login", "")),
|
|
97
|
+
full_name=str(data.get("full_name", "")),
|
|
98
|
+
private=bool(data.get("private", False)),
|
|
99
|
+
archived=bool(data.get("archived", False)),
|
|
100
|
+
fork=bool(data.get("fork", False)),
|
|
101
|
+
mirror=bool(data.get("mirror", False)),
|
|
102
|
+
default_branch=data.get("default_branch") or None,
|
|
103
|
+
description=data.get("description") or None,
|
|
104
|
+
language=data.get("language") or None,
|
|
105
|
+
html_url=data.get("html_url") or None,
|
|
106
|
+
has_actions=bool(data.get("has_actions", False)),
|
|
107
|
+
# open_issues_count excludes pull requests on Forgejo and Gitea;
|
|
108
|
+
# open_pr_counter is the separate figure. Adding them together
|
|
109
|
+
# would double-count nothing, but reporting either as "issues"
|
|
110
|
+
# silently changes meaning between forge versions.
|
|
111
|
+
open_issues=int(data.get("open_issues_count", 0)),
|
|
112
|
+
open_pull_requests=int(data.get("open_pr_counter", 0)),
|
|
113
|
+
stars=int(data.get("stars_count", 0)),
|
|
114
|
+
forks=int(data.get("forks_count", 0)),
|
|
115
|
+
watchers=int(data.get("watchers_count", 0)),
|
|
116
|
+
releases=int(data.get("release_counter", 0)),
|
|
117
|
+
size_kb=int(data.get("size", 0)),
|
|
118
|
+
updated_at=_parse_timestamp(data.get("updated_at")),
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True, slots=True)
|
|
123
|
+
class WorkflowRun:
|
|
124
|
+
"""One Forgejo Actions run."""
|
|
125
|
+
|
|
126
|
+
id: int
|
|
127
|
+
name: str | None
|
|
128
|
+
workflow_id: str | None
|
|
129
|
+
status: str | None
|
|
130
|
+
event: str | None
|
|
131
|
+
head_branch: str | None
|
|
132
|
+
head_sha: str | None
|
|
133
|
+
run_number: int | None
|
|
134
|
+
display_title: str | None
|
|
135
|
+
url: str | None
|
|
136
|
+
started_at: datetime | None
|
|
137
|
+
updated_at: datetime | None
|
|
138
|
+
|
|
139
|
+
@classmethod
|
|
140
|
+
def from_api(cls, data: dict[str, Any]) -> WorkflowRun:
|
|
141
|
+
"""Build from an entry of ``/actions/tasks``."""
|
|
142
|
+
return cls(
|
|
143
|
+
id=int(data.get("id", 0)),
|
|
144
|
+
name=data.get("name") or None,
|
|
145
|
+
workflow_id=data.get("workflow_id") or None,
|
|
146
|
+
status=data.get("status") or None,
|
|
147
|
+
event=data.get("event") or None,
|
|
148
|
+
head_branch=data.get("head_branch") or None,
|
|
149
|
+
head_sha=data.get("head_sha") or None,
|
|
150
|
+
run_number=data.get("run_number"),
|
|
151
|
+
display_title=data.get("display_title") or None,
|
|
152
|
+
url=data.get("url") or None,
|
|
153
|
+
started_at=_parse_timestamp(data.get("run_started_at")),
|
|
154
|
+
updated_at=_parse_timestamp(data.get("updated_at")),
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@dataclass(frozen=True, slots=True)
|
|
159
|
+
class Release:
|
|
160
|
+
"""A published (or drafted) release."""
|
|
161
|
+
|
|
162
|
+
id: int
|
|
163
|
+
tag_name: str
|
|
164
|
+
name: str | None
|
|
165
|
+
draft: bool
|
|
166
|
+
prerelease: bool
|
|
167
|
+
html_url: str | None
|
|
168
|
+
published_at: datetime | None
|
|
169
|
+
|
|
170
|
+
@classmethod
|
|
171
|
+
def from_api(cls, data: dict[str, Any]) -> Release:
|
|
172
|
+
"""Build from an entry of ``/releases``."""
|
|
173
|
+
return cls(
|
|
174
|
+
id=int(data.get("id", 0)),
|
|
175
|
+
tag_name=str(data.get("tag_name", "")),
|
|
176
|
+
name=data.get("name") or None,
|
|
177
|
+
draft=bool(data.get("draft", False)),
|
|
178
|
+
prerelease=bool(data.get("prerelease", False)),
|
|
179
|
+
html_url=data.get("html_url") or None,
|
|
180
|
+
published_at=_parse_timestamp(data.get("published_at")),
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True, slots=True)
|
|
185
|
+
class Commit:
|
|
186
|
+
"""The tip commit of a branch."""
|
|
187
|
+
|
|
188
|
+
sha: str
|
|
189
|
+
message: str | None
|
|
190
|
+
author: str | None
|
|
191
|
+
html_url: str | None
|
|
192
|
+
created_at: datetime | None
|
|
193
|
+
|
|
194
|
+
@classmethod
|
|
195
|
+
def from_api(cls, data: dict[str, Any]) -> Commit:
|
|
196
|
+
"""Build from an entry of ``/commits``."""
|
|
197
|
+
commit = data.get("commit") or {}
|
|
198
|
+
author = commit.get("author") or {}
|
|
199
|
+
message = commit.get("message")
|
|
200
|
+
return cls(
|
|
201
|
+
sha=str(data.get("sha", "")),
|
|
202
|
+
# Only the subject line is useful as a state attribute; the body
|
|
203
|
+
# can run to kilobytes and Home Assistant caps attribute size.
|
|
204
|
+
message=message.strip().splitlines()[0] if message else None,
|
|
205
|
+
author=author.get("name") or None,
|
|
206
|
+
html_url=data.get("html_url") or None,
|
|
207
|
+
created_at=_parse_timestamp(commit.get("created") or data.get("created")),
|
|
208
|
+
)
|
forgejo/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: forgejo
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Async client library for the Forgejo and Gitea REST API
|
|
5
|
+
Author-email: AboveColin <colin@cdevries.dev>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/AboveColin/forgejo
|
|
8
|
+
Project-URL: Issues, https://github.com/AboveColin/forgejo/issues
|
|
9
|
+
Keywords: forgejo,gitea,git,forge,async,api,client
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
17
|
+
Classifier: Topic :: Software Development :: Version Control :: Git
|
|
18
|
+
Classifier: Typing :: Typed
|
|
19
|
+
Requires-Python: >=3.12
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: aiohttp>=3.9
|
|
23
|
+
Requires-Dist: yarl>=1.9
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# forgejo
|
|
27
|
+
|
|
28
|
+
Async Python client for the [Forgejo](https://forgejo.org/) REST API. Forgejo
|
|
29
|
+
keeps API compatibility with Gitea, so this works against Gitea instances too.
|
|
30
|
+
|
|
31
|
+
The library covers the read-only surface needed to monitor a forge: version,
|
|
32
|
+
account, unread notifications, repository counters, the latest Actions run, and
|
|
33
|
+
the tip commit. It has no Home Assistant dependency — if that is what you are
|
|
34
|
+
after, see [HA-Forgejo](https://github.com/AboveColin/HA-Forgejo).
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install forgejo
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Requires Python 3.12+.
|
|
43
|
+
|
|
44
|
+
## Use
|
|
45
|
+
|
|
46
|
+
```python
|
|
47
|
+
import asyncio
|
|
48
|
+
|
|
49
|
+
from forgejo import ForgejoClient
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def main() -> None:
|
|
53
|
+
async with ForgejoClient("https://git.example.com", token="your-api-token") as client:
|
|
54
|
+
server = await client.get_version()
|
|
55
|
+
print(f"Forgejo {server.version}")
|
|
56
|
+
|
|
57
|
+
me = await client.get_authenticated_user()
|
|
58
|
+
print(f"Signed in as {me.login}")
|
|
59
|
+
|
|
60
|
+
print(f"{await client.get_new_notification_count()} unread notifications")
|
|
61
|
+
|
|
62
|
+
repo = await client.get_repository("example-user", "example-repo")
|
|
63
|
+
print(f"{repo.full_name}: {repo.open_issues} issues, {repo.open_pull_requests} PRs")
|
|
64
|
+
|
|
65
|
+
run = await client.get_latest_workflow_run("example-user", "example-repo")
|
|
66
|
+
if run is not None:
|
|
67
|
+
print(f"Last CI run: {run.name} -> {run.status}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
asyncio.run(main())
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Bring your own session
|
|
74
|
+
|
|
75
|
+
Pass an existing `aiohttp.ClientSession` and the client will use it and leave it
|
|
76
|
+
open. This is what you want inside a larger application that already pools
|
|
77
|
+
connections.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
async with aiohttp.ClientSession() as session:
|
|
81
|
+
client = ForgejoClient("https://git.example.com", token="...", session=session)
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### Self-signed certificates
|
|
85
|
+
|
|
86
|
+
Instances on a private network often use a certificate the system trust store
|
|
87
|
+
does not know about.
|
|
88
|
+
|
|
89
|
+
```python
|
|
90
|
+
client = ForgejoClient("https://git.internal", token="...", verify_ssl=False)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Turning verification off means the connection is encrypted but unauthenticated.
|
|
94
|
+
Prefer installing the CA certificate where possible.
|
|
95
|
+
|
|
96
|
+
## Getting a token
|
|
97
|
+
|
|
98
|
+
In the web UI: **Settings → Applications → Generate New Token**. Read-only
|
|
99
|
+
scopes are enough:
|
|
100
|
+
|
|
101
|
+
- `read:repository` — repository counters, Actions runs, commits
|
|
102
|
+
- `read:issue` — issue and pull-request counts
|
|
103
|
+
- `read:notification` — unread notification count
|
|
104
|
+
- `read:user` — the account the token belongs to
|
|
105
|
+
|
|
106
|
+
Everything except `get_version()` needs a token.
|
|
107
|
+
|
|
108
|
+
## API
|
|
109
|
+
|
|
110
|
+
| Method | Returns |
|
|
111
|
+
|---|---|
|
|
112
|
+
| `get_version()` | `ServerInfo` |
|
|
113
|
+
| `get_authenticated_user()` | `User` |
|
|
114
|
+
| `get_new_notification_count()` | `int` |
|
|
115
|
+
| `list_repositories(limit=50)` | `list[Repository]` |
|
|
116
|
+
| `get_repository(owner, repo)` | `Repository` |
|
|
117
|
+
| `get_latest_workflow_run(owner, repo)` | `WorkflowRun \| None` |
|
|
118
|
+
| `get_latest_commit(owner, repo)` | `Commit \| None` |
|
|
119
|
+
|
|
120
|
+
Methods return dataclasses, never raw dictionaries. `None` means the thing does
|
|
121
|
+
not exist — a repository with no workflows, or an empty repository with no
|
|
122
|
+
commits — which is different from an error.
|
|
123
|
+
|
|
124
|
+
## Errors
|
|
125
|
+
|
|
126
|
+
All exceptions derive from `ForgejoError`:
|
|
127
|
+
|
|
128
|
+
- `ForgejoConnectionError` — unreachable or timed out
|
|
129
|
+
- `ForgejoAuthenticationError` — token rejected or missing a scope
|
|
130
|
+
- `ForgejoNotFoundError` — no such repository or endpoint
|
|
131
|
+
- `ForgejoResponseError` — answered, but not with usable JSON
|
|
132
|
+
|
|
133
|
+
A common cause of `ForgejoResponseError` is an auth proxy in front of the
|
|
134
|
+
instance returning its own login page with HTTP 200.
|
|
135
|
+
|
|
136
|
+
## Notes on the API
|
|
137
|
+
|
|
138
|
+
- `open_issues` excludes pull requests. Pull requests are counted separately in
|
|
139
|
+
`open_pull_requests`. This surprises people coming from the GitHub API, where
|
|
140
|
+
the equivalent field includes both.
|
|
141
|
+
- A workflow run status is only meaningful once it reaches a terminal state.
|
|
142
|
+
`TERMINAL_RUN_STATUSES` holds the set; anything else means still running,
|
|
143
|
+
which is not the same as failing.
|
|
144
|
+
|
|
145
|
+
## License
|
|
146
|
+
|
|
147
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
forgejo/__init__.py,sha256=2PE_jQQPgXSkZq3DGZOdJyBubUhfNR9ewABstlgfR0g,761
|
|
2
|
+
forgejo/client.py,sha256=TWnzbvsr88bv84OM312fe3Rl5J5EorVBoWMAqQMOsIA,9474
|
|
3
|
+
forgejo/constants.py,sha256=EobpvJiy4_JXX61KH_G9lcUhpIZd7NFcqGO8lsIx--g,1623
|
|
4
|
+
forgejo/exceptions.py,sha256=e5hmB5WYzbHo_h6Okb5jwWWe27f8EtNfS_TktGTWQ2o,798
|
|
5
|
+
forgejo/models.py,sha256=wC7B3-g5fG1o7amMM9pwqx-0dj6KnGvJs9lqf0kAueo,6898
|
|
6
|
+
forgejo/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
forgejo-1.0.0.dist-info/licenses/LICENSE,sha256=AauGeNSCYrkgorJls9tILMcvzLakZvyCo1EePvLBq48,1067
|
|
8
|
+
forgejo-1.0.0.dist-info/METADATA,sha256=-oUXcwH9MvMKefir4BZH-JJHLqEXGgZhAdv9VuPkgDE,4834
|
|
9
|
+
forgejo-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
10
|
+
forgejo-1.0.0.dist-info/top_level.txt,sha256=rZDoor4Q0AU6Xxc5qrD9tBbl73fmmrTbW58LK8B-SRw,8
|
|
11
|
+
forgejo-1.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 AboveColin
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
forgejo
|