Python API
python-gitea exposes a synchronous client and an asyncio-based async client.
Both are thin wrappers around the
Gitea REST API with the same structure: the
top-level client holds one resource object per Gitea domain (issues, pull
requests, repositories, users, comments, labels, milestones, notifications, and
projects).
Synchronous Client
Use the Gitea client as a context manager. It must be used inside a with
block so its HTTP session is cleaned up:
from gitea.client.gitea import Gitea
with Gitea(token="YOUR_API_TOKEN", base_url="https://gitea.example.com") as client:
issues, _ = client.issue.list_issues(owner="my-org", repository="my-repo")
List-style methods return a tuple (data, status) where data is a list of
dictionaries (or the response payload) and status holds metadata such as the
HTTP status code:
with Gitea(token="TOKEN", base_url="https://gitea.example.com") as client:
repositories, status = client.repository.list_repositories(username="my-org")
print(repositories[0]["name"])
print(status["status_code"])
Mutating operations return the same (data, status) shape. For example,
create_issue returns the created issue as a dictionary plus metadata:
with Gitea(token="TOKEN", base_url="https://gitea.example.com") as client:
issue, status = client.issue.create_issue(
owner="my-org",
repository="my-repo",
title="Hello",
body="Created with python-gitea.",
)
print(issue["number"])
print(status["status_code"])
Where an Issue Sits on a Board
An issue payload lists the projects the issue is on, but Gitea's project objects
carry no column, so the issue alone does not say where on a board its card sits.
resolve_project_column_ids fills that in, giving every project of the issue a
column_id — the column holding its card, or None when the issue has no card
on that project:
from gitea.client.gitea import Gitea
from gitea.issue import resolve_project_column_ids
with Gitea(token="TOKEN", base_url="https://gitea.example.com") as client:
issue, _ = client.issue.get_issue(owner="my-org", repository="my-repo", index=15)
issue = resolve_project_column_ids(
client=client,
owner="my-org",
repository="my-repo",
issue=issue,
)
for project in issue["projects"]:
print(project["id"], project["column_id"])
The columns of each project are walked until the one holding the card is found,
so the cost grows with the size of the board rather than of the repository. The
gitea-cli issue get command does this for you.
resolve_async_project_column_ids is the await-based twin for AsyncGitea.
Both are best-effort, and column_id should be read with that in mind:
- The walk is a sequence of requests, not a snapshot.
column_idreflects the board as it was while the walk ran, so a card moved during it may come back under either column, or asNone. - A lookup that fails - refused, timed out, or lost in transport - is logged as
a warning on the
gitealogger and leaves that project'scolumn_idatNone. The issue itself is returned; the exception is not re-raised, on the synchronous and asynchronous paths alike.Nonetherefore means "no card on this project" and "could not be resolved" alike. column_idis present on every project entry the functions return, including when the issue payload carries no global ID for the columns to be matched by, in which case every column isNone.- The columns of a user-owned (individual) project live under endpoints this
library does not wrap, so such a project's
column_idis alwaysNone.
That last point is about the enrichment and not about the walk itself.
find_card_column_id is the walk on its own — one project, one issue, the
column holding its card or None when no column of the board lists it — and it
raises what the lookup raised rather than logging it, for a caller that has to
tell "no card" apart from "could not be read":
from gitea.client.gitea import Gitea
from gitea.issue import find_card_column_id
with Gitea(token="TOKEN", base_url="https://gitea.example.com") as client:
column_id = find_card_column_id(
client=client,
owner="my-org",
repository=None, # the organization's own project; a repository's names it
project_id=1,
issue_id=1854, # the global ID, which is what the columns list their issues by
)
print("not on the board" if column_id is None else column_id)
find_async_card_column_id is its await-based twin. Asking this before moving
a card is what gitea-cli project issue move does: Gitea's move endpoint moves
the row relating an issue to a project, and for an issue that is not on the
project there is none to move, so the call comes back a success having done
nothing. gitea-cli project issue remove asks it for the other reason — the
removal endpoint takes the column the card is in, and this is what answers that
when the caller passes no --column-id. That command asks it a second time
after removing the card, expecting no column at all: a card moved between the
two calls leaves the removal naming a column that no longer holds it, and the
whole board has to be walked to tell that from a removal that worked.
column_holds_card is the same question about a single named column, at the
cost of that column's listing rather than the board's — which is what the walk
above asks per column, and what the same command asks again after the move to
confirm the card arrived. async_column_holds_card is its await-based twin.
Asynchronous Client
The AsyncGitea client has the same structure but await-based, and it uses
aiohttp:
import asyncio
from gitea.client.async_gitea import AsyncGitea
async def main() -> None:
async with AsyncGitea(token="YOUR_API_TOKEN", base_url="https://gitea.example.com") as client:
issues, _ = await client.issue.list_issues(owner="my-org", repository="my-repo")
print(len(issues))
asyncio.run(main())
Use the async client for concurrent workloads; otherwise the synchronous client is simpler and sufficient.
Available Resources
| Client attribute | Gitea domain |
|---|---|
client.actions |
Actions: workflows, runs, jobs, artifacts, secrets, variables, runners |
client.issue |
Issues and issue dependencies |
client.pull_request |
Pull requests |
client.repository |
Repositories |
client.user |
Users and user settings |
client.comment |
Issue comments |
client.label |
Issue labels |
client.milestone |
Milestones |
client.notification |
Notifications |
client.project |
Projects, columns, and project issues |
client.organization |
Organizations |
Each resource is implemented in a synchronous class (e.g. gitea.issue.Issue)
and an async class (e.g. gitea.issue.AsyncIssue); some modules re-export them
from the package __init__ (e.g. from gitea.issue import Issue). See the
API Reference for the full method list and signatures.
Those package-level re-exports are resolved when the name is first read rather
than when the package is imported, so both spellings work and neither costs more
than it needs to: from gitea.issue import Issue imports gitea.issue.issue at
that moment, and import gitea.issue.base imports neither class. The only way
to notice is by introspection - a name is absent from the package's __dict__
until something reads it, though dir() still lists it.
gitea.actions is the one package that re-exports nothing, so import its two
classes from the modules they live in -
from gitea.actions.actions import Actions. It is thirteen modules where the
others are three, and an eager re-export would make import gitea.actions.scope
execute all thirteen.
Actions
client.actions wraps the Actions endpoints: the workflows of a repository and
dispatching one, the runs and their status, the jobs of a run with their logs,
the artifacts a run produced, and the secrets, variables and runners a workflow
runs against.
Four things about it differ from every other resource here, all because the endpoints themselves differ:
Most listings answer with an object, not an array. total_count sits
alongside workflows, workflow_runs, jobs, artifacts or runners, which
is what Gitea sends, so it is what these methods hand back - keyed as the API
keys it, as the field-name convention requires. The secret and
variable listings are the exception: those two really do answer with a bare
array, so they hand back a list and fall back to the empty one.
runs, _ = client.actions.list_workflow_runs(owner="my-org", repository="my-repo", status="failure", limit=10)
print(runs["total_count"])
for run in runs["workflow_runs"]:
print(run["run_number"], run["status"], run["conclusion"])
Two endpoints answer with a file. get_workflow_job_logs hands back the log
as a string, decoded as UTF-8, and the empty string for a job that has produced
no output yet. download_artifact hands back the zip archive as bytes,
undecoded: decoding it would replace every byte that is not valid UTF-8 and
produce an archive that no longer opens.
logs, metadata = client.actions.get_workflow_job_logs(owner="my-org", repository="my-repo", job_id=118)
print(logs, end="")
from pathlib import Path
archive, metadata = client.actions.download_artifact(owner="my-org", repository="my-repo", artifact_id=9)
Path("dist.zip").write_bytes(archive)
An artifact whose archive has expired answers with no body, so empty bytes mean
the archive is gone rather than empty; expired on the artifact itself is what
says which. Uploading an artifact is not offered, and is not an omission: Gitea
has no REST endpoint for it - an artifact is uploaded from inside a running job,
by the runner, over the Actions protocol.
Most of the API exists at four scopes. Secrets, variables, runners and the run and job listings belong to a repository, to an organization, to the authenticated account or to the instance, at paths that differ while the request does not. Which one a call means is decided by the coordinates it was given, not by the method name:
| Arguments | Scope | Path |
|---|---|---|
owner and repository |
that repository | /repos/{owner}/{repo}/actions/... |
owner alone |
that organization | /orgs/{owner}/actions/... |
| neither | the authenticated account | /user/actions/... |
admin=True |
the whole instance | /admin/actions/... |
client.actions.list_runners(owner="my-org", repository="my-repo") # the repository's own
client.actions.list_runners(owner="my-org") # the organization's
client.actions.list_runners() # the authenticated account's
client.actions.list_runners(admin=True) # the instance's
owner alone is the organization form, not a generic owner form: Gitea
answers it only for an organization.
Not every family offers every scope. A secret cannot be listed for the
authenticated account, and neither secrets nor variables have an instance-wide
form. Asking for one that does not exist raises ValueError naming the scopes
that would have worked, rather than reaching a URL that answers 404 - which
would read as "no secrets" instead of "no such endpoint".
Several endpoints answer without a body. Setting a secret answers 201 when
it was new and 204 when it replaced one; deleting anything answers 204;
rerunning only the failed jobs of a run answers 201 with nothing. In each case
the payload is the empty object and metadata["status_code"] is what says what
happened.
_, metadata = client.actions.create_or_update_secret(
secret_name="DEPLOY_TOKEN", data=token, owner="my-org", repository="my-repo"
)
created = metadata["status_code"] == 201
A secret is write-only: nothing reads its value back, which is why there is no
get_secret. A variable is its readable counterpart, with the value under
data - and where a secret has one endpoint that both creates and replaces, a
variable has two: create_variable refuses a name that exists, and
update_variable replaces one that does. update_variable requires the value
even when only the name is changing, so read it first rather than guessing it.
Dispatching answers 204 with no body, which says the request was accepted but
not which run it started. Ask for the run to be named when you mean to follow
it; an instance too old to know the parameter answers without a body as before,
so an empty payload is "accepted, run not named" rather than a failure.
details, metadata = client.actions.dispatch_workflow(
owner="my-org",
repository="my-repo",
workflow_id="build.yml", # the file name, which is what the endpoint takes
ref="refs/heads/main",
inputs={"environment": "staging"},
return_run_details=True,
)
run_id = details.get("workflow_run_id")
Cancelling a run has two endpoints and not one, chosen by an argument rather
than by a method of its own: cancel_workflow_run asks the run's jobs to stop
and waits for them to notice, which a job whose runner has gone away never does,
and force=True marks the run cancelled regardless. Rerunning is the same
shape: failed_jobs_only=True is a different endpoint, and the one to reach for
when a long run failed on one flaky job.
run, _ = client.actions.cancel_workflow_run(owner="my-org", repository="my-repo", run_id=42)
print(run["status"]) # says whether the cancellation has taken effect yet
client.actions.cancel_workflow_run(owner="my-org", repository="my-repo", run_id=42, force=True)
client.actions.rerun_workflow_run(owner="my-org", repository="my-repo", run_id=42, failed_jobs_only=True)
delete_workflow_run deletes the run's jobs, logs and artifacts with it, which
is how a repository is cleared of those rather than only of the run's entry; a
run that has not finished cannot be deleted.
Field Names
A payload returned by a client method is the JSON the Gitea API sent, keyed as the API keys it. Nothing is renamed and nothing is dropped, in the client dictionaries and in the CLI's JSON envelope alike, so a key is looked up in Gitea's API reference and not in this library's source.
A project column is therefore title:
columns, _ = client.project.list_project_columns(owner="my-org", repository=None, project_id=31)
print(columns[0]["title"]) # 'Working'
name reads it too. It is an alias, not a second key: reading a payload by it
works, and enumerating or serializing the payload sees title alone, so nothing
downstream can come to depend on a name the API does not use.
column = columns[0]
column["name"] # 'Working' - reads the title
column.get("name") # 'Working'
"name" in column # True
list(column) # ['id', 'title', 'default', 'sorting', ...] - no 'name'
json.dumps(column) # {"id": 117, "title": "Working", ...} - no 'name'
Only reading is widened. column["name"] = x writes a field called name, as
it would on any dictionary, rather than changing the title through a name Gitea
does not use.
gitea.utils.fields holds the convention itself, the record types declaring the
aliases of a resource, and the bar an alias has to clear to be added: a name
callers have written, not a name that reads well. Today there is one, name on
a project column.
Detecting What Changed
gitea.watch is not a resource: it holds the change detection that
gitea-cli watch list
is built on, for a caller wanting the same comparison without the command's
choices about scopes and output.
Both listings below are walked with collect_all_pages, as the command walks
them. A single call returns one page, so comparing against list_issues(...)[0]
would report every issue past the first page as gone, and every comment past the
first page of an issue's comments would never be seen at all.
from gitea.client.gitea import Gitea
from gitea.utils.pagination import PAGE_SIZE, collect_all_pages
from gitea.watch import detect_changes, issue_key, issue_snapshot, load_state, save_scopes, scope_snapshots
OWNER, REPOSITORY = "my-org", "my-repo"
SCOPE = f"repo:{OWNER}/{REPOSITORY}"
state = load_state("watch-state.json")
with Gitea(token="your-token", base_url="https://gitea.example.com") as client:
issues, _ = collect_all_pages(
lambda page: client.issue.list_issues(
owner=OWNER, repository=REPOSITORY, state="open", page=page, limit=PAGE_SIZE
)
)
current = {}
for issue in issues:
comments, _ = collect_all_pages(
lambda page, index=issue["number"]: client.comment.list_comments(
owner=OWNER, repository=REPOSITORY, index=index, page=page, limit=PAGE_SIZE
)
)
current[issue_key(issue)] = issue_snapshot(issue, comments, repository=f"{OWNER}/{REPOSITORY}")
# None - a scope never recorded - baselines it, so nothing is reported the
# first time round.
for change in detect_changes(current, scope_snapshots(state, SCOPE)):
print(change["kind"], change["number"], change["detail"])
save_scopes("watch-state.json", {SCOPE: current})
Baselining applies to a scope and not to an issue. An issue the recorded scope
has not seen is compared against an empty snapshot, so detect_changes reports
new for it and then reports the assignees, labels and comments it already
carries as added - which is what keeps a comment written before the issue was
first seen from being folded into the baseline and never reported.
issue_snapshot reduces an issue to the fields the comparison reads,
comment_hash identifies a comment stably across re-fetches - by the author's
ID rather than their login, so renaming a user does not look like every comment
they wrote being replaced - and the state helpers read and write the cache the
snapshots live in.
save_scopes re-reads the cache and replaces only the scopes it is given, under
a lock on a .lock file beside it, so a caller running concurrently with
another does not erase what the other recorded. Prefer it to save_state, which
writes a whole document unlocked and is for a caller that has one - a caller
holding the whole cache is claiming every scope in it. Both write atomically.
Reading tolerates a cache that is missing or unreadable, and discards one written by an older version of this library rather than comparing against digests taken over something else. The CLI documentation describes what those cost.