forgejo 1.0.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.
forgejo-1.0.0/LICENSE ADDED
@@ -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.
forgejo-1.0.0/PKG-INFO ADDED
@@ -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,122 @@
1
+ # forgejo
2
+
3
+ Async Python client for the [Forgejo](https://forgejo.org/) REST API. Forgejo
4
+ keeps API compatibility with Gitea, so this works against Gitea instances too.
5
+
6
+ The library covers the read-only surface needed to monitor a forge: version,
7
+ account, unread notifications, repository counters, the latest Actions run, and
8
+ the tip commit. It has no Home Assistant dependency — if that is what you are
9
+ after, see [HA-Forgejo](https://github.com/AboveColin/HA-Forgejo).
10
+
11
+ ## Install
12
+
13
+ ```bash
14
+ pip install forgejo
15
+ ```
16
+
17
+ Requires Python 3.12+.
18
+
19
+ ## Use
20
+
21
+ ```python
22
+ import asyncio
23
+
24
+ from forgejo import ForgejoClient
25
+
26
+
27
+ async def main() -> None:
28
+ async with ForgejoClient("https://git.example.com", token="your-api-token") as client:
29
+ server = await client.get_version()
30
+ print(f"Forgejo {server.version}")
31
+
32
+ me = await client.get_authenticated_user()
33
+ print(f"Signed in as {me.login}")
34
+
35
+ print(f"{await client.get_new_notification_count()} unread notifications")
36
+
37
+ repo = await client.get_repository("example-user", "example-repo")
38
+ print(f"{repo.full_name}: {repo.open_issues} issues, {repo.open_pull_requests} PRs")
39
+
40
+ run = await client.get_latest_workflow_run("example-user", "example-repo")
41
+ if run is not None:
42
+ print(f"Last CI run: {run.name} -> {run.status}")
43
+
44
+
45
+ asyncio.run(main())
46
+ ```
47
+
48
+ ### Bring your own session
49
+
50
+ Pass an existing `aiohttp.ClientSession` and the client will use it and leave it
51
+ open. This is what you want inside a larger application that already pools
52
+ connections.
53
+
54
+ ```python
55
+ async with aiohttp.ClientSession() as session:
56
+ client = ForgejoClient("https://git.example.com", token="...", session=session)
57
+ ```
58
+
59
+ ### Self-signed certificates
60
+
61
+ Instances on a private network often use a certificate the system trust store
62
+ does not know about.
63
+
64
+ ```python
65
+ client = ForgejoClient("https://git.internal", token="...", verify_ssl=False)
66
+ ```
67
+
68
+ Turning verification off means the connection is encrypted but unauthenticated.
69
+ Prefer installing the CA certificate where possible.
70
+
71
+ ## Getting a token
72
+
73
+ In the web UI: **Settings → Applications → Generate New Token**. Read-only
74
+ scopes are enough:
75
+
76
+ - `read:repository` — repository counters, Actions runs, commits
77
+ - `read:issue` — issue and pull-request counts
78
+ - `read:notification` — unread notification count
79
+ - `read:user` — the account the token belongs to
80
+
81
+ Everything except `get_version()` needs a token.
82
+
83
+ ## API
84
+
85
+ | Method | Returns |
86
+ |---|---|
87
+ | `get_version()` | `ServerInfo` |
88
+ | `get_authenticated_user()` | `User` |
89
+ | `get_new_notification_count()` | `int` |
90
+ | `list_repositories(limit=50)` | `list[Repository]` |
91
+ | `get_repository(owner, repo)` | `Repository` |
92
+ | `get_latest_workflow_run(owner, repo)` | `WorkflowRun \| None` |
93
+ | `get_latest_commit(owner, repo)` | `Commit \| None` |
94
+
95
+ Methods return dataclasses, never raw dictionaries. `None` means the thing does
96
+ not exist — a repository with no workflows, or an empty repository with no
97
+ commits — which is different from an error.
98
+
99
+ ## Errors
100
+
101
+ All exceptions derive from `ForgejoError`:
102
+
103
+ - `ForgejoConnectionError` — unreachable or timed out
104
+ - `ForgejoAuthenticationError` — token rejected or missing a scope
105
+ - `ForgejoNotFoundError` — no such repository or endpoint
106
+ - `ForgejoResponseError` — answered, but not with usable JSON
107
+
108
+ A common cause of `ForgejoResponseError` is an auth proxy in front of the
109
+ instance returning its own login page with HTTP 200.
110
+
111
+ ## Notes on the API
112
+
113
+ - `open_issues` excludes pull requests. Pull requests are counted separately in
114
+ `open_pull_requests`. This surprises people coming from the GitHub API, where
115
+ the equivalent field includes both.
116
+ - A workflow run status is only meaningful once it reaches a terminal state.
117
+ `TERMINAL_RUN_STATUSES` holds the set; anything else means still running,
118
+ which is not the same as failing.
119
+
120
+ ## License
121
+
122
+ MIT
@@ -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
+ ]
@@ -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])
@@ -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
+ )
@@ -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."""
@@ -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
+ )
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,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ forgejo/__init__.py
5
+ forgejo/client.py
6
+ forgejo/constants.py
7
+ forgejo/exceptions.py
8
+ forgejo/models.py
9
+ forgejo/py.typed
10
+ forgejo.egg-info/PKG-INFO
11
+ forgejo.egg-info/SOURCES.txt
12
+ forgejo.egg-info/dependency_links.txt
13
+ forgejo.egg-info/requires.txt
14
+ forgejo.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ aiohttp>=3.9
2
+ yarl>=1.9
@@ -0,0 +1 @@
1
+ forgejo
@@ -0,0 +1,41 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "forgejo"
7
+ version = "1.0.0"
8
+ description = "Async client library for the Forgejo and Gitea REST API"
9
+ readme = "README.md"
10
+ license = { text = "MIT" }
11
+ requires-python = ">=3.12"
12
+ authors = [{ name = "AboveColin", email = "colin@cdevries.dev" }]
13
+ keywords = ["forgejo", "gitea", "git", "forge", "async", "api", "client"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Framework :: AsyncIO",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: Software Development :: Version Control :: Git",
23
+ "Typing :: Typed",
24
+ ]
25
+ dependencies = ["aiohttp>=3.9", "yarl>=1.9"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/AboveColin/forgejo"
29
+ Issues = "https://github.com/AboveColin/forgejo/issues"
30
+
31
+ [tool.setuptools.packages.find]
32
+ include = ["forgejo*"]
33
+
34
+ [tool.setuptools.package-data]
35
+ forgejo = ["py.typed"]
36
+
37
+ [tool.pylint.main]
38
+ py-version = "3.12"
39
+
40
+ [tool.pylint."messages control"]
41
+ disable = ["too-many-instance-attributes", "too-many-arguments", "too-few-public-methods"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+