GhApi details

Detailed information on the GhApi API

You can set an environment variable named GH_HOST to override the default of https://api.github.com incase you are running GitHub Enterprise(GHE). However, this library has not been tested on GHE, so proceed at your own risk.

from httpx import Response
from contextlib import redirect_stdout

source

Debug summaries exclude the Authorization header regardless of its casing.

out = io.StringIO()
headers = {'AUTHORIZATION': 'secret', 'Accept': 'application/json'}
with redirect_stdout(out): print_summary('GET', GH_HOST, dict(headers=headers))
summary = out.getvalue()
assert 'secret' not in summary and 'application/json' in summary
summary
"GET https://api.github.com {'headers': {'Accept': 'application/json'}}\n"

source

GhSyncTransport

def GhSyncTransport(
    debug:NoneType=None, limit_cb:NoneType=None, **kwargs
):

Sync twin of GhTransport: same debug, header, and rate-limit handling over a blocking SyncTransport


source

GhTransport

def GhTransport(
    debug:NoneType=None, limit_cb:NoneType=None, **kwargs
):

Async transport converting JSON responses to AttrDicts and tracking rate-limit and response headers.

GitHub’s diff media type is text even when its content type is not text/plain. Binary downloads remain bytes.

diff_response = Response(200, content=b'+new line', headers={'Content-Type': 'application/vnd.github.v3.diff'})
archive_response = Response(200, content=b'PK', headers={'Content-Type': 'application/zip'})
test_eq(GhTransport._decode(diff_response), '+new line')
test_eq(GhTransport._decode(archive_response), b'PK')
GhTransport._decode(diff_response)
'+new line'

source

gh_patch

def gh_patch(
    fn
):

patch fn into GhApi, adding owner/repo params that override the client defaults for this call and its internal calls

gh_patch adds per-call owner and repo overrides to convenience methods. Nested calls inherit the override without changing the client’s defaults. _LiveDefaults reads the active override from a ContextVar, keeping concurrent tasks separate. The issue-reading example below demonstrates this through the public API.


source

GhApi

def GhApi(
    owner:NoneType=None, # Default repository owner; endpoint calls can override it
    repo:NoneType=None, # Default repository name; endpoint calls can override it
    token:NoneType=None, # Personal access token; otherwise GITHUB_TOKEN
    jwt_token:NoneType=None, # JWT; otherwise GITHUB_JWT_TOKEN; takes precedence over token
    debug:NoneType=None, # Request callback (method, url, kwargs); print_summary omits authorization
    limit_cb:NoneType=None, # Callback (remaining, quota) when rate-limit headers change
    gh_host:NoneType=None, # API base URL; otherwise GH_HOST
    authenticate:bool=True, # False skips credentials; missing credentials otherwise warn and remain unauthenticated
    timeout:float=60.0, # Request timeout in seconds
    sync:bool=False, # Generated endpoints block instead of returning awaitables; async convenience methods still need an async client
    **kwargs
):

GitHub API client. Endpoint groups (issues, pulls, …) are generated per-instance from GitHub’s OpenAPI metadata, so the class shows only convenience methods – inspect a live instance, e.g. doc(GhApi()), to see the full API.

Access by path


source

GhApi.__call__

def __call__(
    path:str, verb:str=None, headers:dict=None, route:dict=None, query:dict=None, data:NoneType=None
):

Call a fully specified path (or full URL) using HTTP verb directly (returns an awaitable on an async client)


source

GhApi.__call__

def __call__(
    path:str, verb:str=None, headers:dict=None, route:dict=None, query:dict=None, data:NoneType=None
):

Call a fully specified path (or full URL) using HTTP verb directly (returns an awaitable on an async client)

api = GhApi()

Call api(path, verb, ...) to make a request directly. Put path substitutions in route, query parameters in query, and the request body in data:

await api('/repos/{owner}/{repo}/git/ref/{ref}', 'GET', route=dict(
    owner='fastai', repo='ghapi-test', ref='heads/master'))
{ 'node_id': 'MDM6UmVmMzE1NzEyNTg4OnJlZnMvaGVhZHMvbWFzdGVy',
  'object': { 'sha': 'b72d6c87a9237ca3c26298a64a6acf06217ace4a',
              'type': 'commit',
              'url': 'https://api.github.com/repos/fastai/ghapi-test/git/commits/b72d6c87a9237ca3c26298a64a6acf06217ace4a'},
  'ref': 'refs/heads/master',
  'url': 'https://api.github.com/repos/fastai/ghapi-test/git/refs/heads/master'}

source

GhApi.__getitem__

def __getitem__(
    k
):

Lookup an endpoint by path and verb (which defaults to ‘GET’)


source

GhApi.__getitem__

def __getitem__(
    k
):

Lookup an endpoint by path and verb (which defaults to ‘GET’)

Index api by an endpoint path to get its callable operation. Pass the endpoint parameters directly; the operation puts them in the path, query or request body. This repeats the previous request:

await api['/repos/{owner}/{repo}/git/ref/{ref}'](owner='fastai', repo='ghapi-test', ref='heads/master')
{ 'node_id': 'MDM6UmVmMzE1NzEyNTg4OnJlZnMvaGVhZHMvbWFzdGVy',
  'object': { 'sha': 'b72d6c87a9237ca3c26298a64a6acf06217ace4a',
              'type': 'commit',
              'url': 'https://api.github.com/repos/fastai/ghapi-test/git/commits/b72d6c87a9237ca3c26298a64a6acf06217ace4a'},
  'ref': 'refs/heads/master',
  'url': 'https://api.github.com/repos/fastai/ghapi-test/git/refs/heads/master'}

Media types

For some endpoints GitHub lets you specify a media type the for response data, using the Accept header. If you choose a media type that is not JSON formatted (for instance application/vnd.github.v3.sha) then the call to the GhApi object will return a string instead of an object.

await api('/repos/{owner}/{repo}/commits/{ref}', 'GET', route=dict(owner='fastai', repo='ghapi-test', ref='refs/heads/master'),
    headers={'Accept': 'application/vnd.github.VERSION.sha'})
'b72d6c87a9237ca3c26298a64a6acf06217ace4a'

Rate limits

GitHub has various rate limits for their API. After each call, the response includes information about how many requests are remaining in the hourly quota. If you’d like to add alerts, or indications showing current quota usage, you can register a callback with GhApi by passing a callable to the limit_cb parameter. This callback will be called whenever the amount of quota used changes. It will be called with two arguments: the new quota remaining, and the total hourly quota.

def _f(rem,quota): print(f"Quota remaining: {rem} of {quota}")

api = GhApi(limit_cb=_f)
(await api['/repos/{owner}/{repo}/git/ref/{ref}'](owner='fastai', repo='ghapi-test', ref='heads/master')).ref
Quota remaining: 4931 of 5000
'refs/heads/master'

You can always get the remaining quota from the limit_rem attribute:

api.limit_rem
'4931'

Sync usage

GhApi endpoint calls are async by default. Pass sync=True when you need blocking calls that return their results directly. The endpoint groups, names and signatures are the same:

sapi = GhApi(owner='fastai', repo='ghapi-test', sync=True)
test_eq(sapi.repos.get().name, 'ghapi-test')
test_eq(sapi['/repos/{owner}/{repo}/git/ref/{ref}'](owner='fastai', repo='ghapi-test', ref='heads/master').object.type, 'commit')

Async convenience methods such as read_issue and create_gist still need an async client. From synchronous code, use fastcore.net.run_sync, which also works in Jupyter:

api = GhApi(owner='fastai', repo='ghapi')
run_sync(api.read_issue(205))

Pagination has synchronous versions, sync_paged and sync_pages (see page). For a one-off endpoint call, call_gh creates a synchronous client and calls the operation:


source

call_gh

def call_gh(
    op:str, *args, token:NoneType=None, **kwargs
):

Call one GitHub operation op (e.g. 'repos.get') on a fresh sync client; handy for one-off calls from sync code

test_eq(call_gh('repos.get', owner='fastai', repo='ghapi-test').name, 'ghapi-test')

Operations

Instead of passing a path to GhApi, you will more often use the operation methods provided in the API’s operation groups, which include documentation, signatures, and auto-complete.

Pass owner, repo or other endpoint parameters to the constructor to set their defaults. Operation calls use these defaults when they need the parameters. Direct calls such as api(path, ...) do not use them.

Authenticated endpoints require a token. Pass it as token. If you omit it, GhApi uses GITHUB_TOKEN when available.

api = GhApi(owner='AnswerDotAI', repo='ghapi-test', token=token)

Operation groups

Display api to list its endpoint groups and links to their documentation:

api.codes_of_conduct

Calling endpoints

The GitHub API’s endpoint names generally start with a verb like “get”, “list”, “delete”, “create”, etc, followed _, then by a noun such as “ref”, “webhook”, “issue”, etc.

Each endpoint has a different signature, which you can see by using Shift-Tab in Jupyter, or by just printing the endpoint object (which also shows a link to the GitHub docs):

print(api.repos.create_webhook)
repos.create_webhook(name: str = UNSET, config: dict = UNSET, owner: str = 'AnswerDotAI', repo: str = 'ghapi-test', events: list = ['push'], active: bool = True)
https://docs.github.com/rest/repos/webhooks#create-a-repository-webhook

Displaying an endpoint object in Jupyter also provides a formatted summary and link to the official GitHub documentation:

api.repos.create_webhook

Create a repository webhook

Docs: https://docs.github.com/rest/repos/webhooks#create-a-repository-webhook

Parameters: - name (str, optional): Use web to create a webhook. Default: web. This parameter only accepts the value web. - config (dict, optional): Key/value pairs to provide settings for this webhook. - owner (str, default: ‘AnswerDotAI’): The account owner of the repository. The name is not case sensitive. - repo (str, default: ‘ghapi-test’): The name of the repository without the .git extension. The name is not case sensitive. - events (list, default: [‘push’]): Determines what events the hook is triggered for. - active (bool, default: True): Determines if notifications are sent when the webhook is triggered. Set to true to send notifications.

Endpoint objects are called using standard Python method syntax:

ref = await api.git.get_ref('heads/master')
test_eq(ref.object.type, 'commit')

Information about the endpoint are available as attributes:

api.git.get_ref.path,api.git.get_ref.verb
('/repos/{owner}/{repo}/git/ref/{ref}', 'GET')

You can get a list of all endpoints available in a group, along with a link to documentation for each, by viewing the group:

api.git

For “list” endpoints, the noun will be a plural form, e.g.:

hooks = await api.repos.list_webhooks()
test_eq(len(hooks), 0)

You can pass dicts, lists, etc. directly, where they are required for GitHub API endpoints:

url = 'https://example.com'
cfg = dict(url=url, content_type='json', secret='XXX')
hook = await api.repos.create_webhook(config=cfg, events=['ping'])
test_eq(hook.config.url, url)

Let’s confirm that our new webhook has been created:

hooks = await api.repos.list_webhooks()
test_eq(len(hooks), 1)
test_eq(hooks[0].events, ['ping'])

Finally, we can delete our new webhook:

await api.repos.delete_webhook(hooks[0].id)

Convenience functions


source

date2gh

def date2gh(
    dt:datetime.datetime
)->str:

Convert dt to GitHub’s UTC timestamp format; naive datetimes are assumed UTC

The GitHub API assumes that dates will be in a specific string format. date2gh converts Python standard datetime objects to that format. For instance, to find issues opened in the ‘fastcore’ repo in the last 4 weeks:

dt = date2gh(datetime.now(timezone.utc) - timedelta(weeks=4))
issues = await GhApi('fastai').issues.list_for_repo(repo='fastcore', since=dt)
len(issues)
2

source

gh2date

def gh2date(
    dtstr:str
)->datetime.datetime:

Convert a GitHub UTC date string to a naive UTC datetime

gh2date returns a naive datetime in UTC. date2gh assumes UTC for naive values and converts aware values to UTC before adding Z.

created = '2026-08-01T09:30:00Z'
dt = gh2date(created)
test_eq(dt.tzinfo, None)
test_eq(date2gh(dt), created)
aware = dt.replace(tzinfo=timezone.utc)
test_eq(date2gh(aware), created)
test_eq(date2gh(aware.astimezone(timezone(timedelta(hours=2)))), created)
dt
datetime.datetime(2026, 8, 1, 9, 30)

Set debug to a callback to inspect requests before sending them. It gets the method, URL and request keyword arguments. print_summary prints this information without the authentication token. Set GHAPI_DEBUG to enable it globally:

api.debug=print_summary
(await api.codes_of_conduct.get_all_codes_of_conduct())[0]
api.debug=None
GET https://api.github.com/codes_of_conduct {'headers': {}, 'params': {}, 'json_data': None}

Convenience methods

GhRows displays one line per result. Convenience methods use it to show identifiers and summaries without printing the full API response.


source

GhRows

def GhRows(
    items:NoneType=None, *rest, use_list:bool=False, match:NoneType=None
):

Result rows whose bare display is one actionable line each

Some methods in the GitHub API are a bit clunky or unintuitive. In these situations we add convenience methods to GhApi to make things simpler. There are also some multi-step processes in the GitHub API that GhApi provide convenient wrappers for. The methods currently available are shown below; do not hesitate to create an issue or pull request if there are other processes that you’d like to see supported better.


source

GhApi.create_gist

async def create_gist(
    description, content, filename:str='gist.txt', public:bool=False, img_paths:NoneType=None
):

Create a gist, optionally with images where each md img url will be placed with img upload urls.

create_gist accepts text directly. Uploaded images replace matching Markdown image URLs in the gist.

gist = await api.create_gist("some description", "some content")
print(gist.html_url)
https://gist.github.com/jph00/d68e0a59cd9fc8e984797d29898b8317
gist.files['gist.txt'].content
'some content'

Pass img_paths to upload local images alongside the Markdown file.

gist = await api.create_gist("some description", "some image\n\n![image](puppy.jpg)", 'gist.md', img_paths=['../puppy.jpg'])
print(gist.html_url)
https://gist.github.com/jph00/929068e9fa15a998e3c58bbb54e1bd24
gist.files['gist.md'].content
'some image\n\n![image](https://gist.githubusercontent.com/jph00/929068e9fa15a998e3c58bbb54e1bd24/raw/c7f420c839f58c6ac0c05f1116317645d31d7e80/puppy.jpg)'

Note that if you want to create a gist with multiple files, call the GitHub API directly, e.g.:

api.gists.create("some description", files={"f1.txt": {"content": "my content"}, ...})

source

GhApi.update_gist

async def update_gist(
    gist_id:str, content:str
):

Update the first file in a gist with new content


source

GhApi.gist_file

def gist_file(
    gist_id:str
):

Get the first file from a gist; coro if async client


source

GhApi.load_gist

def load_gist(
    gist_id:str
):

Retrieve a gist by id, or by user/id (as it appears in gist URLs); coro if async client

load_gist accepts a gist ID or a user/id string from its URL. gist_file and update_gist use the first file. For a gist with multiple files, call api.gists.get or api.gists.update directly.

load_gist and gist_file support both client modes. They return awaitables on an async client and results on a synchronous client.

gistid = 'jph00/e7cfd4ded593e8ef6217e78a0131960c'
loaded = await api.load_gist(gistid)
test_eq(loaded.id, 'e7cfd4ded593e8ef6217e78a0131960c')

gfile = await api.gist_file(gistid)
assert gfile.content

sapi = GhApi(sync=True)
test_eq(sapi.load_gist(gistid).id, 'e7cfd4ded593e8ef6217e78a0131960c')
assert sapi.gist_file(gistid).content

update_gist changes the first file and returns the gist URL. This example creates a temporary gist and deletes it afterwards.

g = await api.create_gist('update_gist test', 'v1')
url = await api.update_gist(g.id, 'v2')
test_eq(url, g.html_url)
for _ in range(20):
    content = first((await api.gists.get(g.id)).files.values()).content
    if content == 'v2': break
    sleep(1)
test_eq(content, 'v2')
await api.gists.delete(g.id)

Releases


source

show_doc

def show_doc(
    sym, # Symbol to document
    renderer:NoneType=None, # Optional renderer (defaults to markdown)
    name:str | None=None, # Optionally override displayed name of `sym`
    title_level:int=3, # Heading level to use for symbol name
):

Show signature and docstring for sym


source

GhApi.upload_file

async def upload_file(
    rel, fn
):

Upload fn to endpoint for release rel

create_release creates a release and uploads its files. It accepts the arguments for repos.create_release, plus a files argument containing one filename or a list.

Pass generate_release_notes=True for GitHub to write the release notes from the merged pull requests.

Omitted optional values such as make_latest use GitHub’s defaults. Pass make_latest='false' for an update to an older version that should not replace the repository’s “Latest” release:

rel = await api.create_release('0.0.1', files=['../README.md'])
test_eq(rel.name, 'v0.0.1')
for _ in range(20):
    rels = await api.repos.list_releases()
    if len(rels) >= 1: break
    sleep(0.5)
test_eq(len(rels), 1)

We can check that our file has been uploaded; GitHub refers to them as “assets”:

assets = await api.repos.list_release_assets(rels[0].id)
test_eq(assets[0].name, 'README.md')

source

show_doc

def show_doc(
    sym, # Symbol to document
    renderer:NoneType=None, # Optional renderer (defaults to markdown)
    name:str | None=None, # Optionally override displayed name of `sym`
    title_level:int=3, # Heading level to use for symbol name
):

Show signature and docstring for sym


GhApi.delete_release

async def delete_release(
    release, *, owner:fastcore.xtras.Unset=UNSET, repo:fastcore.xtras.Unset=UNSET
):

Delete a release and its associated tag

We can delete our release and confirm that it is removed:

await api.delete_release(rels[0])
test_eq(len(await api.repos.list_releases()), 0)

Branches and tags


source

show_doc

def show_doc(
    sym, # Symbol to document
    renderer:NoneType=None, # Optional renderer (defaults to markdown)
    name:str | None=None, # Optionally override displayed name of `sym`
    title_level:int=3, # Heading level to use for symbol name
):

Show signature and docstring for sym

With no prefix, all tags are listed.

test_eq(len(await api.list_tags()), 1)

Using the full tag name will return just that tag.

test_eq(len(await api.list_tags(rel.tag_name)), 1)

source

show_doc

def show_doc(
    sym, # Symbol to document
    renderer:NoneType=None, # Optional renderer (defaults to markdown)
    name:str | None=None, # Optionally override displayed name of `sym`
    title_level:int=3, # Heading level to use for symbol name
):

Show signature and docstring for sym

Branches can be listed in the exactly the same way as tags.

test_eq(len(await api.list_branches('master')), 1)

source

show_doc

def show_doc(
    sym, # Symbol to document
    renderer:NoneType=None, # Optional renderer (defaults to markdown)
    name:str | None=None, # Optionally override displayed name of `sym`
    title_level:int=3, # Heading level to use for symbol name
):

Show signature and docstring for sym

create_branch_empty starts a branch with a placeholder file and no parent commit.

ref = await api.create_branch_empty("testme")
test_eq(len(await api.list_branches('testme')), 1)

source

show_doc

def show_doc(
    sym, # Symbol to document
    renderer:NoneType=None, # Optional renderer (defaults to markdown)
    name:str | None=None, # Optionally override displayed name of `sym`
    title_level:int=3, # Heading level to use for symbol name
):

Show signature and docstring for sym


source

show_doc

def show_doc(
    sym, # Symbol to document
    renderer:NoneType=None, # Optional renderer (defaults to markdown)
    name:str | None=None, # Optionally override displayed name of `sym`
    title_level:int=3, # Heading level to use for symbol name
):

Show signature and docstring for sym

delete_branch removes the branch reference. The change can take a short time to appear in branch listings.

await api.delete_branch('testme')
for _ in range(20):
    if not await api.list_branches('testme'): break
    sleep(0.5)
test_eq(len(await api.list_branches('testme')), 0)

source

show_doc

def show_doc(
    sym, # Symbol to document
    renderer:NoneType=None, # Optional renderer (defaults to markdown)
    name:str | None=None, # Optionally override displayed name of `sym`
    title_level:int=3, # Heading level to use for symbol name
):

Show signature and docstring for sym


source

show_doc

def show_doc(
    sym, # Symbol to document
    renderer:NoneType=None, # Optional renderer (defaults to markdown)
    name:str | None=None, # Optionally override displayed name of `sym`
    title_level:int=3, # Heading level to use for symbol name
):

Show signature and docstring for sym

Creating or updating a branch with several files normally requires separate tree, commit, and reference calls. commit_tree performs that sequence as one commit. It accepts GitHub tree entries, preserving the API’s normal rules for modes, deletions, and blobs.

branch,path = 'testme','commit-tree.txt'
tree = [dict(path=path, mode='100644', type='blob', content='first')]

commit1 = await api.commit_tree(branch, 'Create test branch', tree)
test_eq((await api.get_branch(branch)).object.sha, commit1.sha)

A second commit on the same branch keeps the first commit as its parent.

tree[0]['content'] = 'second'
commit2 = await api.commit_tree(branch, 'Update test branch', tree)
test_eq(commit2.parents[0].sha, commit1.sha)
test_eq((await api.get_branch(branch)).object.sha, commit2.sha)

content = await api.repos.get_content(path=path, ref=branch)
test_eq(base64.b64decode(content.content).decode(), 'second')

await api.delete_branch(branch)
commit1.sha,commit2.sha

Content (git files)


source

show_doc

def show_doc(
    sym, # Symbol to document
    renderer:NoneType=None, # Optional renderer (defaults to markdown)
    name:str | None=None, # Optionally override displayed name of `sym`
    title_level:int=3, # Heading level to use for symbol name
):

Show signature and docstring for sym

list_files returns the branch’s top-level tree, indexed by path.

files = await api.list_files()
files['README.md']
{ 'mode': '100644',
  'path': 'README.md',
  'sha': 'eaea0f2698e76c75602058bf4e2e9fd7940ac4e3',
  'size': 72,
  'type': 'blob',
  'url': 'https://api.github.com/repos/AnswerDotAI/ghapi-test/git/blobs/eaea0f2698e76c75602058bf4e2e9fd7940ac4e3'}

source

show_doc

def show_doc(
    sym, # Symbol to document
    renderer:NoneType=None, # Optional renderer (defaults to markdown)
    name:str | None=None, # Optionally override displayed name of `sym`
    title_level:int=3, # Heading level to use for symbol name
):

Show signature and docstring for sym

get_content decodes GitHub’s base64 content field to bytes.

readme = (await api.get_content('README.md')).decode()
assert 'ghapi' in readme

source

show_doc

def show_doc(
    sym, # Symbol to document
    renderer:NoneType=None, # Optional renderer (defaults to markdown)
    name:str | None=None, # Optionally override displayed name of `sym`
    title_level:int=3, # Heading level to use for symbol name
):

Show signature and docstring for sym


source

show_doc

def show_doc(
    sym, # Symbol to document
    renderer:NoneType=None, # Optional renderer (defaults to markdown)
    name:str | None=None, # Optionally override displayed name of `sym`
    title_level:int=3, # Heading level to use for symbol name
):

Show signature and docstring for sym

create_file accepts text or bytes. create_or_update_file also accepts a blob sha when replacing an existing file.

person = dict(name="Monalisa Octocat", email="[email protected]")
res = await api.create_file(path='foo', message="Create foo", content="foobar", committer=person, author=person)
test_eq('foobar', (await api.get_content('foo')).decode())

source

show_doc

def show_doc(
    sym, # Symbol to document
    renderer:NoneType=None, # Optional renderer (defaults to markdown)
    name:str | None=None, # Optionally override displayed name of `sym`
    title_level:int=3, # Heading level to use for symbol name
):

Show signature and docstring for sym

delete_file resolves the current blob SHA on the selected branch when sha is omitted.

await api.delete_file('foo', 'delete foo', committer=person, author=person)
assert 'foo' not in await api.list_files()

source

show_doc

def show_doc(
    sym, # Symbol to document
    renderer:NoneType=None, # Optional renderer (defaults to markdown)
    name:str | None=None, # Optionally override displayed name of `sym`
    title_level:int=3, # Heading level to use for symbol name
):

Show signature and docstring for sym

update_contents replaces an existing file. An omitted sha is looked up on the selected branch.

res = await api.update_contents(path='README.md', message="Update README", committer=person, author=person, content=readme+"foobar")
res.content.size
78
readme = (await api.get_content('README.md')).decode()
assert 'foobar' in readme
await api.update_contents('README.md', "Revert README", committer=person, author=person, content=readme[:-6]);
api = GhApi(token=token)

The filtering examples use a fixed fastcore commit. Their file counts do not depend on later changes to the repository.

owner, repo, branch = "AnswerDotAI", "fastcore", "6c22f5d5a79cb8c9edbd51847d2e7b56c92727a3"

Repo files are filtered with fnmatch Unix shell-style wildcards. A file must match an include pattern when any are supplied. An exclude match always removes it, even when an include pattern names the file explicitly:

assert not _include('README.md', ['README.md'], ['*.md'])
assert not _include('CONTRIBUTING.md', ['README.md'], ['*.md'])

Include all .py files except for tests

assert not _include('examples/test_fastcore2.py', ['*.py'], ['*test_*', '*/test*/*'])
assert not _include('examples/tests/some_test.py', ['*.py'], ['*test_*', '*/tests/*'])
assert not _include('examples/test/some_test.py', ['*.py'], ['*test_*', '*/test/*'])
assert _include('cool/module.py', ['*.py'], ['setup.py'])
assert not _include('cool/_modidx', ['*.py'], ['*/_modidx'])
assert not _include('setup.py', ['*.py'], ['setup.py'])
test_repo_files = ['README.md', 'CONTRIBUTING.md', 'dir/MARKDOWN.md', 'tests/file.py', 'module/file.py', 'module/app/file.py',
    'nbs/00.ipynb', 'file2.py', '.gitignore', 'module/.dotfile', '_hidden.py', 'module/_hidden.py']

This example applies these filters:

  • Include README.md, Python files and notebooks.
  • Exclude Python files under tests/.
  • Exclude paths with a component starting with _.
inc,exc = ['README.md', '*.py', '*.ipynb'], ['tests/*.py', '_*', '*/_*']
[fn for fn in test_repo_files if _include(fn,inc,exc)]
['README.md',
 'module/file.py',
 'module/app/file.py',
 'nbs/00.ipynb',
 'file2.py']

Let’s exclude files starting with test_ and setup.py too.

exc += ['*test_*.py', '*/*test*.py', 'setup.py']
exc
['tests/*.py', '_*', '*/_*', '*test_*.py', '*/*test*.py', 'setup.py']

A function to get repo files with optional filtering


source

GhApi.get_repo_files

async def get_repo_files(
    owner, repo, branch:str='main', inc:NoneType=None, exc:NoneType=None
):

Get all file items of a repo, optionally filtered.

The list of files that are kept based on the filtering logic:

repo_files = await api.get_repo_files(owner, repo, branch, inc=inc, exc=exc)
test_eq(len(repo_files), 44)
repo_files.attrgot("path")
['README.md', 'fastcore/all.py', 'fastcore/ansi.py', 'fastcore/basics.py', 'fastcore/dispatch.py', 'fastcore/docments.py', 'fastcore/docscrape.py', 'fastcore/foundation.py', 'fastcore/imghdr.py', 'fastcore/imports.py', 'fastcore/meta.py', 'fastcore/nb_imports.py', 'fastcore/nbio.py', 'fastcore/net.py', 'fastcore/parallel.py', 'fastcore/py2pyi.py', 'fastcore/script.py', 'fastcore/shutil.py', 'fastcore/style.py', 'fastcore/tools.py', 'fastcore/transform.py', 'fastcore/utils.py', 'fastcore/xdg.py', 'fastcore/xml.py', 'fastcore/xtras.py', 'nbs/000_tour.ipynb', 'nbs/00_test.ipynb', 'nbs/01_basics.ipynb', 'nbs/02_foundation.ipynb', 'nbs/03_xtras.ipynb', 'nbs/03a_parallel.ipynb', 'nbs/03b_net.ipynb', 'nbs/04_docments.ipynb', 'nbs/05_meta.ipynb', 'nbs/06_script.ipynb', 'nbs/07_xdg.ipynb', 'nbs/08_style.ipynb', 'nbs/09_xml.ipynb', 'nbs/10_py2pyi.ipynb', 'nbs/11_external.ipynb', 'nbs/12_tools.ipynb', 'nbs/13_nbio.ipynb', 'nbs/index.ipynb', 'tests/minimal.ipynb']

source

GhApi.get_repo_contents

async def get_repo_contents(
    owner, repo, branch:str='main', n_workers:int=16, # Max concurrent downloads
    *, inc:NoneType=None, exc:NoneType=None
):

Get all file items of a repo, optionally filtered.


source

GhApi.get_file_content

async def get_file_content(
    path, owner, repo, branch:str='main'
):

get_repo_contents downloads the matching files concurrently. Each result retains its metadata and adds UTF-8 text in content_decoded.

contents = await api.get_repo_contents(owner, repo, branch, inc=inc, exc=exc)
md = "\n\n".join(f"**[{o.path}]({o.html_url})**\n```{o.path.split('.')[-1]}\n{chr(10).join(o.content_decoded.split(chr(10))[:5])}\n```" for o in contents[:3])
display(Markdown(md))

README.md

# Welcome to fastcore


<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->

fastcore/all.py

from .imports import *
from .foundation import *
from .utils import *
from .parallel import *
from .net import *

fastcore/ansi.py

"Filters for processing ANSI colors."

# Copyright (c) IPython Development Team.
# Modifications by Jeremy Howard.

GitHub Pages


source

show_doc

def show_doc(
    sym, # Symbol to document
    renderer:NoneType=None, # Optional renderer (defaults to markdown)
    name:str | None=None, # Optionally override displayed name of `sym`
    title_level:int=3, # Heading level to use for symbol name
):

Show signature and docstring for sym

branch is set to the default branch if None. path must be /docs or /. Calling it on a repo that already has a Pages site updates that site’s source.

api = GhApi(owner='AnswerDotAI', repo='ghapi-test', token=token)
res = await api.enable_pages(branch='new-branch', path='/')

test_eq(res.source.branch, 'new-branch')
test_eq(res.source.path, '/')

res = await api.enable_pages(branch='new-branch', path='/docs')
test_eq(res.source.path, '/docs')

await api.repos.delete_pages_site()
await api.delete_branch('new-branch')

Issues and pull requests

read_issue collects an issue or PR’s title, body and comments. For PRs, it also fetches the diff, inline review comments and reviews. This combines several API calls. General PR comments belong to GitHub’s Issues API, separate from inline review comments in the Pulls API.


source

show_doc

def show_doc(
    sym, # Symbol to document
    renderer:NoneType=None, # Optional renderer (defaults to markdown)
    name:str | None=None, # Optionally override displayed name of `sym`
    title_level:int=3, # Heading level to use for symbol name
):

Show signature and docstring for sym

api = GhApi(owner='fastai', repo='ghapi', token=token)

pr = await api.read_issue(205)
assert pr.is_pr
assert pr.diff.startswith('diff --git')
test_eq(len(pr.review_comments), 1)
test_eq(len(pr.reviews), 1)
pr

Use Content-Type to determine response parsing (PR)

Replaces the hardcoded _decode_response endpoint list with Content-Type based response handling.

JSON endpoints return AttrDict, text endpoints return str, binary endpoints return bytes — all determined by the response Content-Type header, not a maintained list of paths.

Supersedes #204.

diff: 4 files, 190 lines (see .diff)

0 comments; 1 review comments; 1 commented

An issue can have an associated commit diff without being a pull request.

iss = await api.read_issue(206)
assert not iss.is_pr
assert 'diff' in iss and iss.diff.startswith('diff --git')
assert '(issue)' in repr(iss)
iss

Pass owner or repo to override the client’s defaults for one convenience-method call. The override also applies to nested calls. Here list_files uses fastcore for both the branch lookup and tree request:

iss = await api.read_issue(1, repo='fastcore')
assert iss.title
files = await api.list_files(repo='fastcore')
assert 'fastcore' in files
test_eq((await api.repos.get()).name, 'ghapi')

Diff summaries omit unchanged context and generated module indexes. Folder filtering retains whole file sections.

diff = r"""diff --git a/pkg/core.py b/pkg/core.py
--- a/pkg/core.py
+++ b/pkg/core.py
@@ -1,2 +1,2 @@
 unchanged
-old
+new
diff --git a/pkg/_modidx.py b/pkg/_modidx.py
-generated
+index
"""
changes = _reduce_ctx(_filter_diff(diff, folder='pkg/'))
assert '+new' in changes and '-old' in changes
assert 'unchanged' not in changes and '_modidx.py' not in changes
Markdown(f'```diff\n{changes}\n```')
diff --git a/pkg/core.py b/pkg/core.py
--- a/pkg/core.py
+++ b/pkg/core.py
@@ -1,2 +1,2 @@
-old
+new

source

read_pr

async def read_pr(
    pr_number:int | str, # Issue/PR number, or GitHub issue/PR URL
    owner:str=None, # Owner (not needed if URL passed)
    repo:str=None, # Repo (not needed if URL passed)
    folder:str='', # For diffs, limit to only files in `folder`
    replies:bool=False, # Include comments, review comments, and reviews?
):

Fetch a GitHub PR or issue as one markdown string: title, body, diff (if any), and optionally replies

read_pr returns Markdown containing the title, body and a reduced diff with file headers and changed lines. Set replies=True to include general comments, inline review comments and review verdicts. Pass a number with owner and repo, or a full GitHub URL.

res = await read_pr('https://github.com/fastai/ghapi/pull/205', replies=True)
for s in ('# Use Content-Type', '## Diff', '## Review comments', '## Reviews'): assert s in res

res = await read_pr(206, 'fastai', 'ghapi')
assert '## Diff' in res

with expect_fail(ValueError): await read_pr(205)

source

pr_file_diff

async def pr_file_diff(
    pr_number:int | str, # Issue/PR number, or GitHub issue/PR URL
    filename:str, # File to get the diff for
    owner:str=None, # Owner (not needed if URL passed)
    repo:str=None, # Repo (not needed if URL passed)
)->str:

Get the untruncated patch/diff for a single file in a PR

pr_file_diff is the better choice when you want the complete, untruncated patch for one specific file with addition/deletion counts. read_pr(folder=...) is better for getting a reduced overview of all changes in a subdirectory along with the PR context.

res = await pr_file_diff('https://github.com/fastai/ghapi/pull/205', 'ghapi/core.py')
for s in ('## ghapi/core.py (+9 -18)', '```diff'): assert s in res

source

PullRow

def PullRow(
    *args, **kwargs
):

One pull request as an actionable line

Pull-request rows distinguish a merged PR from a closed, unmerged PR.

merged = PullRow(number=42, updated_at=datetime.now(timezone.utc).isoformat(),
    state='closed', merged_at='2026-08-01T09:30:00Z', title='Handle repository overrides')
test_eq(_pr_mark(merged), 'merged ')
closed = PullRow(merged, merged_at=None)
test_eq(_pr_mark(closed), 'closed ')
GhRows([merged, closed])
#42  0m  merged Handle repository overrides
#42  0m  closed Handle repository overrides

source

show_doc

def show_doc(
    sym, # Symbol to document
    renderer:NoneType=None, # Optional renderer (defaults to markdown)
    name:str | None=None, # Optionally override displayed name of `sym`
    title_level:int=3, # Heading level to use for symbol name
):

Show signature and docstring for sym


source

PullRows

def PullRows(
    items, maxlen:int=180
):

Pull request rows, displayed with titles truncated to maxlen

list_prs displays one line per PR, most recently updated first. Each line gives its number, time since last activity and title. It uses GhRows, like check_status, to keep the list readable without printing each PR’s nested JSON:

prs = await api.list_prs(state='all', per_page=5)
test_eq(len(prs), 5)

lines = repr(prs).splitlines()
test_eq(len(lines), 5)
assert all(l.startswith('#') for l in lines)
assert 'html_url' not in repr(prs)
test_eq(repr(prs[0]), prs[0].line())
assert max(len(l) for l in repr(await api.list_prs(state='all', per_page=5, maxlen=20)).splitlines()) < 50
prs

The REST API doesn’t apply a repository’s issue templates. Use issue_template to fetch them before creating an issue. It parses YAML forms into sections. If the repository has no templates, it checks the owner’s .github community-health repository.


source

show_doc

def show_doc(
    sym, # Symbol to document
    renderer:NoneType=None, # Optional renderer (defaults to markdown)
    name:str | None=None, # Optionally override displayed name of `sym`
    title_level:int=3, # Heading level to use for symbol name
):

Show signature and docstring for sym

For example, quarto-cli uses yml issue forms; each parsed template lists the ### section labels a compliant issue body needs:

qapi = GhApi(owner='quarto-dev', repo='quarto-cli', token=token)
tmpls = await qapi.issue_template()
bug = first(t for t in tmpls if 'bug' in t.name)
assert bug.sections and all(s.label for s in bug.sections)
test_eq(await GhApi(owner='fastai', repo='ghapi', token=token).issue_template(), [])
[s.label for s in bug.sections]
['I have:',
 'Bug description',
 'Steps to reproduce',
 'Actual behavior',
 'Expected behavior',
 'Your environment',
 'Quarto check output']

source

issue_body

def issue_body(
    tmpl, sections
):

Build an issue body following form tmpl from issue_template: ### <label> headings in template order. sections maps label to content (for checkbox sections: list of checked options, or True for all)

issue_body builds the form’s Markdown body from a {label: content} dictionary. It rejects missing required sections and unknown labels:

tmpl = _parse_tmpl('bug_report.yml', """
name: Bug report
description: Report an error
body:
  - type: markdown
    attributes:
      value: Welcome!
  - type: checkboxes
    attributes:
      label: "I have:"
      options:
        - label: searched the issue tracker
        - label: read the docs
  - type: textarea
    attributes:
      label: Bug description
    validations:
      required: true
""")
test_eq([s.label for s in tmpl.sections], ['I have:', 'Bug description'])
body = issue_body(tmpl, {'I have:': True, 'Bug description': 'It breaks.'})
test_eq(body, '### I have:\n\n- [x] searched the issue tracker\n- [x] read the docs\n\n### Bug description\n\nIt breaks.')
body = issue_body(tmpl, {'I have:': ['read the docs'], 'Bug description': 'It breaks.'})
assert '- [ ] searched the issue tracker\n- [x] read the docs' in body
test_fail(lambda: issue_body(tmpl, {'I have:': True}), contains='required')
test_fail(lambda: issue_body(tmpl, {'Bug description': 'x', 'Wrong': 'y'}), contains='template')

Check / CI status


source

CommitStatus

def CommitStatus(
    *args, **kwargs
):

dict subclass that also provides access to keys as attrs, and has a pretty markdown repr


source

CheckRun

def CheckRun(
    *args, **kwargs
):

dict subclass that also provides access to keys as attrs, and has a pretty markdown repr

A running check has no duration yet. Completed runs show elapsed time beside their conclusion.

check = CheckRun(id=123, name='tests', status='in_progress', conclusion=None,
    started_at='2026-08-01T09:30:00Z', completed_at=None)
test_eq(_dur(check), '')
check.update(status='completed', conclusion='success', completed_at='2026-08-01T09:31:05Z')
assert '1m05s' in _dur(check)
check
123  tests: success (1m05s)

source

show_doc

def show_doc(
    sym, # Symbol to document
    renderer:NoneType=None, # Optional renderer (defaults to markdown)
    name:str | None=None, # Optionally override displayed name of `sym`
    title_level:int=3, # Heading level to use for symbol name
):

Show signature and docstring for sym


source

show_doc

def show_doc(
    sym, # Symbol to document
    renderer:NoneType=None, # Optional renderer (defaults to markdown)
    name:str | None=None, # Optionally override displayed name of `sym`
    title_level:int=3, # Heading level to use for symbol name
):

Show signature and docstring for sym

GitHub Actions uses the Checks API. Other CI services can report through the Commit Status API. check_status queries both and returns their results together. Display it to see the verdict and one-line rows with the IDs needed for further requests:

sha = (await api.actions.list_workflow_runs_for_repo(per_page=1)).workflow_runs[0].head_sha
st = await api.check_status(sha)
assert 'state' in st and 'statuses' in st and 'check_runs' in st
assert len(st.check_runs) > 0
st

failure

  • build: failure

pr_status checks the pull request’s head commit. This older PR has no check runs.

pr_st = await api.pr_status(205)
assert 'state' in pr_st and 'statuses' in pr_st and 'check_runs' in pr_st

test_eq(pr_st.check_runs, [])
pr_st

Pass an Actions check-run ID from check_status to failed_step_log. The check run and workflow job share an ID.

failed_step_log retrieves the failed steps’ logs from the run archive. Each section starts with its step name. It removes timestamps and ANSI colors.

The archive usually has a separate file for each step, and failed_step_log prefers it. The combined job log lacks these boundaries, and second-resolution timestamps cannot reliably separate neighboring steps.

Some archives hold only the job’s combined log, with no file for each step. The step’s start and end times then select its lines. Those times have one-second resolution, so a few lines from neighboring steps can be included. A failed step’s log ends with its ##[error] line, so later lines are dropped.

log = '''2026-09-20T19:57:50.1Z Current runner version
2026-09-20T19:57:51.5Z ##[group]Run tests
2026-09-20T19:58:01.7Z ##[error]Process completed with exit code 1.
2026-09-20T19:58:01.9Z Post job cleanup.
2026-09-20T19:58:02.0Z Cleaning up orphan processes'''
buf = io.BytesIO()
with zipfile.ZipFile(buf, 'w') as z: z.writestr('0_test.txt', log)
step = dict2obj(dict(number=2, started_at='2026-09-20T19:57:51Z', completed_at='2026-09-20T19:58:01Z'))
lines = _step_lines(zipfile.ZipFile(buf), dict2obj(dict(name='test')), step)
test_eq(len(lines), 2)
lines

Actions logs wrap setup noise, such as image pulls and tool installs, in ##[group] blocks. GitHub’s log page shows these folded. failed_step_log folds them too, so the display budget goes to the failure. A group stays whole when it holds an error or warning, or when it never finished.

log = ['##[group]Run tests', 'cmd', '##[endgroup]',
       '##[group]Pull Docker image', *['layer']*30, '##[endgroup]',
       '##[group]Install', *['pkg']*30, '##[warning]old input', '##[endgroup]',
       '##[group]Build', *['Compiling']*30, '##[error]failed']
res = _fold_groups(log)
test_eq(len(res), 6 + 33 + 32)
res[:7]

source

show_doc

def show_doc(
    sym, # Symbol to document
    renderer:NoneType=None, # Optional renderer (defaults to markdown)
    name:str | None=None, # Optionally override displayed name of `sym`
    title_level:int=3, # Heading level to use for symbol name
):

Show signature and docstring for sym

Logs expire, typically after 90 days. This saved example shows an intermittent fastcore CI failure. Pass the failing run’s ID from the status display:

await api.failed_step_log(31337421700)

Truncated output:

# Run tests
##[group]Run nbdev-test
...
AssertionError in /Users/runner/work/fastcore/fastcore/nbs/03c_aio.ipynb:
===========================================================================

While Executing Cell #21:
Traceback (most recent call last):
  ...
  File "<ipython-input-1-a2eaf525b891>", line 10, in <module>
    assert elapsed < 0.18, elapsed
           ^^^^^^^^^^^^^^
AssertionError: 0.18208718299865723

nbdev Tests Failed On The Following Notebooks:
==================================================
    03c_aio.ipynb
##[error]Process completed with exit code 1.

Dependency order

Related repositories can fail CI together when a shared dependency breaks. Check dependencies before their dependents. These helpers build a graph from pyproject.toml files and order repositories by dependency.

The graph is a dictionary of {package: (repo_name, [dependency_packages])}. You can also supply a graph from another source. dep_key extracts the package name from a PEP 508 dependency spec:


source

dep_key

def dep_key(
    dep
):

Package key for PEP 508 dependency spec dep

dep_key removes extras, version constraints, and environment markers before casefolding the package name.

test_eq(dep_key('fastcore[all] >=2.1 ; python_version < "3.12"'), 'fastcore')
test_eq(dep_key('Pillow'), 'pillow')
dep_key('httpx >=0.27')
'httpx'

source

local_dep_graph

def local_dep_graph(
    root
):

Dependency graph {package: (repo dir, [dep packages])} for checkouts under root

local_dep_graph reads pyproject.toml files one directory below the root. Use it when you have local checkouts, or pass its result to dep_graph to avoid fetching those repositories again:

tomls = dict(appy='name = "appy"\ndependencies = ["LibX[all] >=1", "httpx"]',
    libx='name = "LibX"\ndependencies = ["exty"]', exty='name = "exty"', toolz='name = "toolz"')
with tempfile.TemporaryDirectory() as tmp:
    d = Path(tmp)
    for dirname, toml in tomls.items():
        (d/dirname).mkdir()
        (d/dirname/'pyproject.toml').write_text(f'[project]\n{toml}\n')
    smallg = local_dep_graph(d)
test_eq(smallg['libx'], ('libx', ['exty']))
smallg
{'appy': ('appy', ['libx', 'httpx']),
 'exty': ('exty', []),
 'libx': ('libx', ['exty']),
 'toolz': ('toolz', [])}

source

dep_closure

def dep_closure(
    name, graph
):

Repo names for name and its transitive dependencies within graph

Graph keys are casefolded package names. Dependencies can refer to packages outside the graph, such as httpx here. dep_closure collects a project’s transitive dependencies within the graph and ignores external packages:

test_eq(dep_closure('appy', smallg), {'appy', 'libx', 'exty'})
dep_closure('LibX', smallg)
{'exty', 'libx'}

source

dep_order

def dep_order(
    graph, names:NoneType=None
):

Order names (repo, package, or owner/name specs; default all of graph) dependency-first, ties most-depended-on first

dep_order accepts repository names, package names or owner/name strings. It puts dependencies before their dependents, including dependencies through repositories omitted from the requested list. Here appy follows exty even without the intervening libx in the list.

Among repositories ready at the same step, those with more dependents come first. This puts exty before the independent toolz. A dependency cycle raises an error.

test_eq(dep_order(smallg), ['exty', 'toolz', 'libx', 'appy'])
test_eq(dep_order(smallg, ['o/appy', 'o/exty']), ['o/exty', 'o/appy'])
expect_fail(lambda: dep_order({'a': ('a', ['b']), 'b': ('b', ['a'])}), contains='cycle')
dep_order(smallg, ['appy', 'LibX'])
['LibX', 'appy']

source

dep_dependents

def dep_dependents(
    graph, names:NoneType=None
):

For each of names (default all of graph), the listed names that transitively depend on it, most-depended-on first

dep_dependents lists which other requested repositories transitively depend on each repository. It orders the result by number of dependents. Use this to see how many of the failing repositories a dependency fix could affect:

test_eq(dep_dependents(smallg, ['appy', 'exty', 'toolz'])['exty'], ['appy'])
dep_dependents(smallg)
{'exty': ['appy', 'libx'], 'libx': ['appy'], 'appy': [], 'toolz': []}

dep_graph fetches each repository’s pyproject.toml from GitHub and follows its dependencies. It looks for each dependency as a repository under the same owner. GitHub repository names are case-insensitive.

A dependency that returns 404 is treated as external. An explicitly requested repository without a fetchable pyproject.toml still appears in the graph with no dependencies. Pass an existing graph to reuse known repositories without fetching them.


source

show_doc

def show_doc(
    sym, # Symbol to document
    renderer:NoneType=None, # Optional renderer (defaults to markdown)
    name:str | None=None, # Optionally override displayed name of `sym`
    title_level:int=3, # Heading level to use for symbol name
):

Show signature and docstring for sym

Starting from fastws also finds fastgit, ghapi and their dependencies. External packages such as httpx don’t enter the graph. The graph keeps package and repository names separately: the fastws repository publishes fastws-cli.

gg = await api.dep_graph('AnswerDotAI/fastws')
assert 'fastcore' in gg and 'ghapi' in gg
test_eq(gg['fastws-cli'][0], 'fastws')
dep_order(gg)

To investigate CI failures across related repositories, build the dependency graph from your local checkouts. Pass it to dep_graph to fetch missing repositories from GitHub. Then use dep_order to put dependencies before their dependents:

red = ['AnswerDotAI/MonsterUI', 'AnswerDotAI/solveit', 'AnswerDotAI/shell_sage', 'AnswerDotAI/aidialog', ...]
api = GhApi(owner='AnswerDotAI')
g = await api.dep_graph(*red, graph=local_dep_graph('~/aai-ws'))
dep_order(g, red)

Truncated output:

['AnswerDotAI/MonsterUI', 'AnswerDotAI/aidialog', ..., 'AnswerDotAI/shell_sage', ..., 'AnswerDotAI/solveit']

aidialog comes before shell_sage through dependencies outside the requested list. Check those dependencies before investigating failures in application repositories such as solveit.