from httpx import Response
from contextlib import redirect_stdoutGhApi details
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.
print_summary
def print_summary(
method, url, kwargs
):Debug callback for GhApi(debug=...): print each request with the token (if any) removed
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"
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
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'
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.
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
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)
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'}GhApi.__getitem__
def __getitem__(
k
):Lookup an endpoint by path and verb (which defaults to ‘GET’)
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')).refQuota 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:
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- actions
- activity
- agent_tasks
- agents
- api_insights
- apps
- billing
- campaigns
- checks
- classroom
- code_quality
- code_scanning
- code_security
- codes_of_conduct
- codespaces
- copilot
- copilot_spaces
- credentials
- dependabot
- dependency_graph
- emojis
- enterprise_team_memberships
- enterprise_team_organizations
- enterprise_teams
- gists
- git
- gitignore
- hosted_compute
- interactions
- issues
- licenses
- markdown
- meta
- migrations
- oidc
- orgs
- packages
- private_registries
- projects
- pulls
- rate_limit
- reactions
- repos
- search
- secret_scanning
- security_advisories
- teams
- users
api.codes_of_conduct- codes_of_conduct.get_all_codes_of_conduct(): Get all codes of conduct
- codes_of_conduct.get_conduct_code(key): Get a code 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_webhookCreate 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- git.create_blob(content, owner, repo, encoding): Create a blob
- git.get_blob(file_sha, owner, repo): Get a blob
- git.create_commit(message, tree, parents, author, committer, signature, owner, repo): Create a commit
- git.get_commit(commit_sha, owner, repo): Get a commit object
- git.list_matching_refs(ref, owner, repo): List matching references
- git.get_ref(ref, owner, repo): Get a reference
- git.create_ref(ref, sha, owner, repo): Create a reference
- git.update_ref(ref, sha, owner, repo, force): Update a reference
- git.delete_ref(ref, owner, repo): Delete a reference
- git.create_tag(tag, message, object, type, tagger, owner, repo): Create a tag object
- git.get_tag(tag_sha, owner, repo): Get a tag
- git.create_tree(tree, base_tree, owner, repo): Create a tree
- git.get_tree(tree_sha, recursive, owner, repo): Get a tree
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
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
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)
dtdatetime.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=NoneGET 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.
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.
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", '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'
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"}, ...})GhApi.update_gist
async def update_gist(
gist_id:str, content:str
):Update the first file in a gist with new content
GhApi.gist_file
def gist_file(
gist_id:str
):Get the first file from a gist; coro if async client
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).contentupdate_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
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_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')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)Content (git files)
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'}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 readmeshow_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
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())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()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.size78
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
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']
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.
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))# Welcome to fastcore
<!-- WARNING: THIS FILE WAS AUTOGENERATED! DO NOT EDIT! -->from .imports import *
from .foundation import *
from .utils import *
from .parallel import *
from .net import *"Filters for processing ANSI colors."
# Copyright (c) IPython Development Team.
# Modifications by Jeremy Howard.GitHub Pages
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.
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)
prUse 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)
issPass 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
+newread_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)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 resPull-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
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
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
prsThe 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.
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']
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
CommitStatus
def CommitStatus(
*args, **kwargs
):dict subclass that also provides access to keys as attrs, and has a pretty markdown repr
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)
check123 tests: success (1m05s)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
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
stfailure
- 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_stPass 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)
linesActions 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]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:
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'
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', [])}
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'}
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']
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.
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.