{ "cells": [ { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|default_exp core" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "# GhApi details\n", "\n", "> Detailed information on the GhApi API" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "from fastcore.all import *\n", "from ghapi.metadata import funcs\n", "\n", "import mimetypes,base64\n", "from inspect import signature,Parameter,Signature\n", "from urllib.request import Request\n", "from urllib.error import HTTPError\n", "from urllib.parse import quote\n", "from datetime import datetime,timedelta\n", "from pprint import pprint\n", "from time import sleep\n", "import os" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|hide\n", "from nbdev import *" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "GH_HOST = os.getenv('GH_HOST', \"https://api.github.com\")\n", "_DOC_URL = 'https://docs.github.com/'" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "You can set an environment variable named `GH_HOST` to override the default of `https://api.github.com` incase you are running [GitHub Enterprise](https://github.com/enterprise)(GHE). However, this library has not been tested on GHE, so proceed at your own risk." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "def _preview_hdr(preview): return {'Accept': f'application/vnd.github.{preview}-preview+json'} if preview else {}\n", "\n", "def _mk_param(nm, **kwargs): return Parameter(nm, kind=Parameter.POSITIONAL_OR_KEYWORD, **kwargs)\n", "def _mk_sig_detls(o):\n", " res = {}\n", " if o[0]!=object: res['annotation']=o[0]\n", " res['default'] = o[1] if len(o)>1 else None\n", " return res\n", "def _mk_sig(req_args, opt_args, anno_args):\n", " params = [_mk_param(k) for k in req_args]\n", " params += [_mk_param(k, default=v) for k,v in opt_args.items()]\n", " params += [_mk_param(k, **_mk_sig_detls(v)) for k,v in anno_args.items()]\n", " return Signature(params)\n", "\n", "class _GhObj: pass" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "class _GhVerb(_GhObj):\n", " __slots__ = 'path,verb,tag,name,summary,url,route_ps,params,data,preview,client,__doc__'.split(',')\n", " def __init__(self, path, verb, oper, summary, url, params, data, preview, client, kwargs):\n", " tag,*name = oper.split('/')\n", " name = '__'.join(name)\n", " name = name.replace('-','_')\n", " path,_,_ = partial_format(path, **kwargs)\n", " route_ps = stringfmt_names(path)\n", " __doc__ = summary\n", " data = {o[0]:o[1:] for o in data}\n", " store_attr()\n", "\n", " def __call__(self, *args, headers=None, **kwargs):\n", " headers = {**_preview_hdr(self.preview),**(headers or {})}\n", " d = list(self.data)\n", " flds = [o for o in self.route_ps+self.params+d if o not in kwargs]\n", " for a,b in zip(args,flds): kwargs[b]=a\n", " route_p,query_p,data_p = [{p:kwargs[p] for p in o if p in kwargs}\n", " for o in (self.route_ps,self.params,d)]\n", " return self.client(self.path, self.verb, headers=headers, route=route_p, query=query_p, data=data_p)\n", "\n", " def __str__(self): return f'{self.tag}.{self.name}{signature(self)}\\n{self.doc_url}'\n", " @property\n", " def __signature__(self): return _mk_sig(self.route_ps, dict.fromkeys(self.params), self.data)\n", " __call__.__signature__ = __signature__\n", " @property\n", " def doc_url(self): return _DOC_URL + self.url.replace(\" \",\"_\")\n", "\n", " def _repr_markdown_(self):\n", " params = ', '.join(self.route_ps+self.params+list(self.data))\n", " return f'[{self.tag}.{self.name}]({self.doc_url})({params}): *{self.summary}*'\n", " __repr__ = _repr_markdown_" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "class _GhVerbGroup(_GhObj):\n", " def __init__(self, name, verbs):\n", " self.name,self.verbs = name,verbs\n", " for o in verbs: setattr(self, o.name, o)\n", " def __str__(self): return \"\\n\".join(str(v) for v in self.verbs)\n", " def _repr_markdown_(self): return \"\\n\".join(f'- {v._repr_markdown_()}' for v in self.verbs)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "_docroot = 'https://docs.github.com/rest/reference/'" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "def print_summary(req:Request):\n", " \"Print `Request.summary` with the token (if any) removed\"\n", " pprint(req.summary('Authorization'))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## GhApi -" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "class GhApi(_GhObj):\n", " def __init__(self, owner=None, repo=None, token=None, jwt_token=None, debug=None, limit_cb=None, gh_host=None,\n", " authenticate=True, **kwargs):\n", " self.headers = { 'Accept': 'application/vnd.github.v3+json' }\n", " if authenticate:\n", " token = token or os.getenv('GITHUB_TOKEN', None)\n", " jwt_token = jwt_token or os.getenv('GITHUB_JWT_TOKEN', None)\n", " if jwt_token: self.headers['Authorization'] = 'Bearer ' + jwt_token\n", " elif token: self.headers['Authorization'] = 'token ' + token\n", " else: warn('Neither GITHUB_TOKEN nor GITHUB_JWT_TOKEN found: running as unauthenticated')\n", " if owner: kwargs['owner'] = owner\n", " if repo: kwargs['repo' ] = repo\n", " funcs_ = L(funcs).starmap(_GhVerb, client=self, kwargs=kwargs)\n", " self.func_dict = {f'{o.path}:{o.verb.upper()}':o for o in funcs_}\n", " self.groups = {k.replace('-','_'):_GhVerbGroup(k,v) for k,v in groupby(funcs_, 'tag').items()}\n", " self.debug,self.limit_cb,self.limit_rem = debug,limit_cb,5000\n", " self.gh_host = gh_host or GH_HOST\n", "\n", " def __call__(self, path:str, verb:str=None, headers:dict=None, route:dict=None, query:dict=None, data=None):\n", " \"Call a fully specified `path` using HTTP `verb`, passing arguments to `fastcore.core.urlsend`\"\n", " if verb is None: verb = 'POST' if data else 'GET'\n", " headers = {**self.headers,**(headers or {})}\n", " if not path.startswith(('http://', 'https://')):\n", " path = self.gh_host + path\n", " if route:\n", " for k,v in route.items(): route[k] = quote(str(route[k]))\n", " return_json = ('json' in headers['Accept'])\n", " debug = self.debug if self.debug else print_summary if os.getenv('GHAPI_DEBUG') else None\n", " res,self.recv_hdrs = urlsend(path, verb, headers=headers or None, debug=debug, return_headers=True,\n", " route=route or None, query=query or None, data=data or None, return_json=return_json)\n", " if 'X-RateLimit-Remaining' in self.recv_hdrs:\n", " newlim = self.recv_hdrs['X-RateLimit-Remaining']\n", " if self.limit_cb is not None and newlim != self.limit_rem:\n", " self.limit_cb(int(newlim),int(self.recv_hdrs['X-RateLimit-Limit']))\n", " self.limit_rem = newlim\n", "\n", " return dict2obj(res) if return_json else res\n", "\n", " def __dir__(self): return super().__dir__() + list(self.groups)\n", " def _repr_markdown_(self): return \"\\n\".join(f\"- [{o}]({_docroot + o.replace('_', '-')})\" for o in sorted(self.groups))\n", " def __getattr__(self,k): return self.groups[k] if 'groups' in vars(self) and k in self.groups else stop(AttributeError(k))\n", "\n", " def __getitem__(self, k):\n", " \"Lookup and call an endpoint by path and verb (which defaults to 'GET')\"\n", " a,b = k if isinstance(k,tuple) else (k,'GET')\n", " return self.func_dict[f'{a}:{b.upper()}']\n", "\n", " def full_docs(self):\n", " return '\\n'.join(f'## {gn}\\n\\n{group._repr_markdown_()}\\n' for gn,group in sorted(self.groups.items()))" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|hide\n", "token = os.environ['GITHUB_TOKEN']" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Access by path" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "data": { "text/markdown": [ "---\n", "\n", "[source](https://github.com/fastai/ghapi/blob/master/ghapi/core.py#L110){target=\"_blank\" style=\"float:right; font-size:smaller\"}\n", "\n", "### GhApi.__call__\n", "\n", "> GhApi.__call__ (path:str, verb:str=None, headers:dict=None,\n", "> route:dict=None, query:dict=None, data=None)\n", "\n", "Call a fully specified `path` using HTTP `verb`, passing arguments to `fastcore.core.urlsend`" ], "text/plain": [ "---\n", "\n", "[source](https://github.com/fastai/ghapi/blob/master/ghapi/core.py#L110){target=\"_blank\" style=\"float:right; font-size:smaller\"}\n", "\n", "### GhApi.__call__\n", "\n", "> GhApi.__call__ (path:str, verb:str=None, headers:dict=None,\n", "> route:dict=None, query:dict=None, data=None)\n", "\n", "Call a fully specified `path` using HTTP `verb`, passing arguments to `fastcore.core.urlsend`" ] }, "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ "show_doc(GhApi.__call__)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "api = GhApi()" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "You can call a `GhApi` object as a function, passing in the path to the endpoint, the HTTP verb, and any route, query parameter, or post data parameters as required." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "data": { "text/markdown": [ "```json\n", "{ 'node_id': 'MDM6UmVmMzE1NzEyNTg4OnJlZnMvaGVhZHMvbWFzdGVy',\n", " 'object': { 'sha': '3d27c573b7f9bdea704289567603f1d02e7e113b',\n", " 'type': 'commit',\n", " 'url': 'https://api.github.com/repos/fastai/ghapi-test/git/commits/3d27c573b7f9bdea704289567603f1d02e7e113b'},\n", " 'ref': 'refs/heads/master',\n", " 'url': 'https://api.github.com/repos/fastai/ghapi-test/git/refs/heads/master'}\n", "```" ], "text/plain": [ "{'ref': 'refs/heads/master',\n", " 'node_id': 'MDM6UmVmMzE1NzEyNTg4OnJlZnMvaGVhZHMvbWFzdGVy',\n", " 'url': 'https://api.github.com/repos/fastai/ghapi-test/git/refs/heads/master',\n", " 'object': {'sha': '3d27c573b7f9bdea704289567603f1d02e7e113b',\n", " 'type': 'commit',\n", " 'url': 'https://api.github.com/repos/fastai/ghapi-test/git/commits/3d27c573b7f9bdea704289567603f1d02e7e113b'}}" ] }, "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ "api('/repos/{owner}/{repo}/git/ref/{ref}', 'GET', route=dict(\n", " owner='fastai', repo='ghapi-test', ref='heads/master'))" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "data": { "text/markdown": [ "---\n", "\n", "[source](https://github.com/fastai/ghapi/blob/master/ghapi/core.py#L134){target=\"_blank\" style=\"float:right; font-size:smaller\"}\n", "\n", "### GhApi.__getitem__\n", "\n", "> GhApi.__getitem__ (k)\n", "\n", "Lookup and call an endpoint by path and verb (which defaults to 'GET')" ], "text/plain": [ "---\n", "\n", "[source](https://github.com/fastai/ghapi/blob/master/ghapi/core.py#L134){target=\"_blank\" style=\"float:right; font-size:smaller\"}\n", "\n", "### GhApi.__getitem__\n", "\n", "> GhApi.__getitem__ (k)\n", "\n", "Lookup and call an endpoint by path and verb (which defaults to 'GET')" ] }, "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ "show_doc(GhApi.__getitem__)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "You can access endpoints by indexing into the object. When using the API this way, you do not need to specify what type of parameter (route, query, or post data) is being used. This is, therefore, the same call as above:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "data": { "text/markdown": [ "```json\n", "{ 'node_id': 'MDM6UmVmMzE1NzEyNTg4OnJlZnMvaGVhZHMvbWFzdGVy',\n", " 'object': { 'sha': '3d27c573b7f9bdea704289567603f1d02e7e113b',\n", " 'type': 'commit',\n", " 'url': 'https://api.github.com/repos/fastai/ghapi-test/git/commits/3d27c573b7f9bdea704289567603f1d02e7e113b'},\n", " 'ref': 'refs/heads/master',\n", " 'url': 'https://api.github.com/repos/fastai/ghapi-test/git/refs/heads/master'}\n", "```" ], "text/plain": [ "{'ref': 'refs/heads/master',\n", " 'node_id': 'MDM6UmVmMzE1NzEyNTg4OnJlZnMvaGVhZHMvbWFzdGVy',\n", " 'url': 'https://api.github.com/repos/fastai/ghapi-test/git/refs/heads/master',\n", " 'object': {'sha': '3d27c573b7f9bdea704289567603f1d02e7e113b',\n", " 'type': 'commit',\n", " 'url': 'https://api.github.com/repos/fastai/ghapi-test/git/commits/3d27c573b7f9bdea704289567603f1d02e7e113b'}}" ] }, "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ "api['/repos/{owner}/{repo}/git/ref/{ref}'](owner='fastai', repo='ghapi-test', ref='heads/master')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Media types" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "For some endpoints GitHub lets you specify a [media type](https://docs.github.com/en/rest/overview/media-types) 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." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "'3d27c573b7f9bdea704289567603f1d02e7e113b'" ] }, "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ "api('/repos/{owner}/{repo}/commits/{ref}', 'GET', route=dict(\n", " owner='fastai', repo='ghapi-test', ref='refs/heads/master'),\n", " headers={'Accept': 'application/vnd.github.VERSION.sha'})" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Rate limits" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "GitHub has various [rate limits](https://docs.github.com/rest/overview/resources-in-the-rest-api#rate-limiting) 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." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Quota remaining: 4847 of 5000\n" ] }, { "data": { "text/plain": [ "'refs/heads/master'" ] }, "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ "def _f(rem,quota): print(f\"Quota remaining: {rem} of {quota}\")\n", "\n", "api = GhApi(limit_cb=_f)\n", "api['/repos/{owner}/{repo}/git/ref/{ref}'](owner='fastai', repo='ghapi-test', ref='heads/master').ref" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "You can always get the remaining quota from the `limit_rem` attribute:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "'4847'" ] }, "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ "api.limit_rem" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Operations" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "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.\n", "\n", "If you provide `owner` and/or `repo` to the constructor, they will be automatically inserted into any calls which use them (except when calling `GhApi` as a function). You can also pass any other arbitrary keyword arguments you like to have them used as defaults for any relevant calls.\n", "\n", "You must include a GitHub API token if you need to access any authenticated endpoints. If don't pass the `token` param, then your `GITHUB_TOKEN` environment variable will be used, if available." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "api = GhApi(owner='fastai', repo='ghapi-test', token=token)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Operation groups" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "The following groups of endpoints are provided, which you can list at any time along with a link to documentation for all endpoints in that group, by displaying the `GhApi` object:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "data": { "text/markdown": [ "- [actions](https://docs.github.com/rest/reference/actions)\n", "- [activity](https://docs.github.com/rest/reference/activity)\n", "- [apps](https://docs.github.com/rest/reference/apps)\n", "- [billing](https://docs.github.com/rest/reference/billing)\n", "- [checks](https://docs.github.com/rest/reference/checks)\n", "- [code_scanning](https://docs.github.com/rest/reference/code-scanning)\n", "- [codes_of_conduct](https://docs.github.com/rest/reference/codes-of-conduct)\n", "- [codespaces](https://docs.github.com/rest/reference/codespaces)\n", "- [dependabot](https://docs.github.com/rest/reference/dependabot)\n", "- [dependency_graph](https://docs.github.com/rest/reference/dependency-graph)\n", "- [emojis](https://docs.github.com/rest/reference/emojis)\n", "- [enterprise_admin](https://docs.github.com/rest/reference/enterprise-admin)\n", "- [gists](https://docs.github.com/rest/reference/gists)\n", "- [git](https://docs.github.com/rest/reference/git)\n", "- [gitignore](https://docs.github.com/rest/reference/gitignore)\n", "- [interactions](https://docs.github.com/rest/reference/interactions)\n", "- [issues](https://docs.github.com/rest/reference/issues)\n", "- [licenses](https://docs.github.com/rest/reference/licenses)\n", "- [markdown](https://docs.github.com/rest/reference/markdown)\n", "- [meta](https://docs.github.com/rest/reference/meta)\n", "- [migrations](https://docs.github.com/rest/reference/migrations)\n", "- [oidc](https://docs.github.com/rest/reference/oidc)\n", "- [orgs](https://docs.github.com/rest/reference/orgs)\n", "- [packages](https://docs.github.com/rest/reference/packages)\n", "- [projects](https://docs.github.com/rest/reference/projects)\n", "- [pulls](https://docs.github.com/rest/reference/pulls)\n", "- [rate_limit](https://docs.github.com/rest/reference/rate-limit)\n", "- [reactions](https://docs.github.com/rest/reference/reactions)\n", "- [repos](https://docs.github.com/rest/reference/repos)\n", "- [scim](https://docs.github.com/rest/reference/scim)\n", "- [search](https://docs.github.com/rest/reference/search)\n", "- [secret_scanning](https://docs.github.com/rest/reference/secret-scanning)\n", "- [teams](https://docs.github.com/rest/reference/teams)\n", "- [users](https://docs.github.com/rest/reference/users)" ], "text/plain": [ "<__main__.GhApi>" ] }, "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ "api" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "data": { "text/markdown": [ "- [codes-of-conduct.get_all_codes_of_conduct](https://docs.github.com/rest/reference/codes-of-conduct#get-all-codes-of-conduct)(): *Get all codes of conduct*\n", "- [codes-of-conduct.get_conduct_code](https://docs.github.com/rest/reference/codes-of-conduct#get-a-code-of-conduct)(key): *Get a code of conduct*" ], "text/plain": [ "<__main__._GhVerbGroup>" ] }, "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ "api.codes_of_conduct" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Calling endpoints" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "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.\n", "\n", "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):" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "repos.create_webhook(name: str = None, config: dict = None, events: list = ['push'], active: bool = True)\n", "https://docs.github.com/rest/webhooks/repos#create-a-repository-webhook\n" ] } ], "source": [ "print(api.repos.create_webhook)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Displaying an endpoint object in Jupyter also provides a formatted summary and link to the official GitHub documentation:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "data": { "text/markdown": [ "[repos.create_webhook](https://docs.github.com/rest/webhooks/repos#create-a-repository-webhook)(name, config, events, active): *Create a repository webhook*" ], "text/plain": [ "[repos.create_webhook](https://docs.github.com/rest/webhooks/repos#create-a-repository-webhook)(name, config, events, active): *Create a repository webhook*" ] }, "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ "api.repos.create_webhook" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Endpoint objects are called using standard Python method syntax:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "ref = api.git.get_ref('heads/master')\n", "test_eq(ref.object.type, 'commit')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Information about the endpoint are available as attributes:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "('/repos/fastai/ghapi-test/git/ref/{ref}', 'get')" ] }, "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ "api.git.get_ref.path,api.git.get_ref.verb" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "You can get a list of all endpoints available in a group, along with a link to documentation for each, by viewing the group:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "data": { "text/markdown": [ "- [git.create_blob](https://docs.github.com/rest/reference/git#create-a-blob)(content, encoding): *Create a blob*\n", "- [git.get_blob](https://docs.github.com/rest/reference/git#get-a-blob)(file_sha): *Get a blob*\n", "- [git.create_commit](https://docs.github.com/rest/reference/git#create-a-commit)(message, tree, parents, author, committer, signature): *Create a commit*\n", "- [git.get_commit](https://docs.github.com/rest/reference/git#get-a-commit)(commit_sha): *Get a commit*\n", "- [git.list_matching_refs](https://docs.github.com/rest/reference/git#list-matching-references)(ref): *List matching references*\n", "- [git.get_ref](https://docs.github.com/rest/reference/git#get-a-reference)(ref): *Get a reference*\n", "- [git.create_ref](https://docs.github.com/rest/reference/git#create-a-reference)(ref, sha, key): *Create a reference*\n", "- [git.update_ref](https://docs.github.com/rest/reference/git#update-a-reference)(ref, sha, force): *Update a reference*\n", "- [git.delete_ref](https://docs.github.com/rest/reference/git#delete-a-reference)(ref): *Delete a reference*\n", "- [git.create_tag](https://docs.github.com/rest/reference/git#create-a-tag-object)(tag, message, object, type, tagger): *Create a tag object*\n", "- [git.get_tag](https://docs.github.com/rest/reference/git#get-a-tag)(tag_sha): *Get a tag*\n", "- [git.create_tree](https://docs.github.com/rest/reference/git#create-a-tree)(tree, base_tree): *Create a tree*\n", "- [git.get_tree](https://docs.github.com/rest/reference/git#get-a-tree)(tree_sha, recursive): *Get a tree*" ], "text/plain": [ "<__main__._GhVerbGroup>" ] }, "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ "api.git" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "For \"list\" endpoints, the noun will be a plural form, e.g.:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|hide\n", "for hook in api.repos.list_webhooks():\n", " api.repos.delete_webhook(hook.id)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "hooks = api.repos.list_webhooks()\n", "test_eq(len(hooks), 0)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "You can pass dicts, lists, etc. directly, where they are required for GitHub API endpoints:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "url = 'https://example.com'\n", "cfg = dict(url=url, content_type='json', secret='XXX')\n", "hook = api.repos.create_webhook(config=cfg, events=['ping'])\n", "test_eq(hook.config.url, url)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Let's confirm that our new webhook has been created:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "hooks = api.repos.list_webhooks()\n", "test_eq(len(hooks), 1)\n", "test_eq(hooks[0].events, ['ping'])" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Finally, we can delete our new webhook:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "data": { "text/markdown": [ "```json\n", "{}\n", "```" ], "text/plain": [ "{}" ] }, "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ "api.repos.delete_webhook(hooks[0].id)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Convenience functions" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "def date2gh(dt:datetime)->str:\n", " \"Convert `dt` (which is assumed to be in UTC time zone) to a format suitable for GitHub API operations\"\n", " return f'{dt.replace(microsecond=0).isoformat()}Z'" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "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:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "2" ] }, "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ "dt = date2gh(datetime.utcnow() - timedelta(weeks=4))\n", "issues = GhApi('fastai').issues.list_for_repo(repo='fastcore', since=dt)\n", "len(issues)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "def gh2date(dtstr:str)->datetime:\n", " \"Convert date string `dtstr` received from a GitHub API operation to a UTC `datetime`\"\n", " return datetime.fromisoformat(dtstr.replace('Z', ''))" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "2022-09-10T06:47:38Z -> 2022-09-10 06:47:38\n" ] } ], "source": [ "created = issues[0].created_at\n", "print(created, '->', gh2date(created))" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "You can set the `debug` attribute to any callable to intercept all requests, for instance to print `Request.summary`. `print_summary` is provided for this purpose. Using this, we can see the preview header that is added for preview functionality, e.g." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "{'data': None,\n", " 'full_url': 'https://api.github.com/codes_of_conduct',\n", " 'headers': {'Accept': 'application/vnd.github.v3+json'},\n", " 'method': 'GET'}\n" ] } ], "source": [ "api.debug=print_summary\n", "api.codes_of_conduct.get_all_codes_of_conduct()[0]\n", "api.debug=None" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Preview endpoints" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "GitHub's preview API functionality requires a special header to be passed to enable it. This is added automatically for you." ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Convenience methods" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "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](https://github.com/fastai/ghapi-test/issues) or pull request if there are other processes that you'd like to see supported better." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "@patch\n", "def create_gist(self:GhApi, description, content, filename='gist.txt', public=False):\n", " \"Create a gist containing a single file\"\n", " return self.gists.create(description, public=public, files={filename: {\"content\": content}})" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "('https://gist.github.com/b3e4356506c67cfa311e555edc3cd20a', 'some content')" ] }, "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ "gist = api.create_gist(\"some description\", \"some content\")\n", "gist.html_url, gist.files['gist.txt'].content" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "data": { "text/markdown": [ "```json\n", "{}\n", "```" ], "text/plain": [ "{}" ] }, "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ "api.gists.delete(gist.id)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Note that if you want to create a gist with multiple files, call the GitHub API directly, e.g.:\n", "\n", "```python\n", "api.gists.create(\"some description\", files={\"f1.txt\": {\"content\": \"my content\"}, ...})\n", "```" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Releases" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "@patch\n", "def delete_release(self:GhApi, release):\n", " \"Delete a release and its associated tag\"\n", " self.repos.delete_release(release.id)\n", " self.git.delete_ref(f'tags/{release.tag_name}')" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|hide\n", "for rel in api.repos.list_releases(): api.delete_release(rel)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "@patch\n", "def upload_file(self:GhApi, rel, fn):\n", " \"Upload `fn` to endpoint for release `rel`\"\n", " fn = Path(fn)\n", " url = rel.upload_url.replace('{?name,label}','')\n", " mime = mimetypes.guess_type(fn, False)[0] or 'application/octet-stream'\n", " return self(url, 'POST', headers={'Content-Type':mime}, query = {'name':fn.name}, data=fn.read_bytes())" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "@patch\n", "def create_release(self:GhApi, tag_name, branch='master', name=None, body='',\n", " draft=False, prerelease=False, files=None):\n", " \"Wrapper for `GhApi.repos.create_release` which also uploads `files`\"\n", " if name is None: name = 'v'+tag_name\n", " rel = self.repos.create_release(tag_name, target_commitish=branch, name=name, body=body,\n", " draft=draft, prerelease=prerelease)\n", " for file in listify(files): self.upload_file(rel, file)\n", " return rel" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Creating a release and attaching files to it is normally a multi-stage process, so `create_release` wraps this up for you. It takes the same arguments as [`repos.create_release`](https://docs.github.com/rest/reference/repos#create-a-release), along with `files`, which can contain a single file name, or a list of file names to upload to your release:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "rel = api.create_release('0.0.1', files=['README.md'])\n", "test_eq(rel.name, 'v0.0.1')" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "sleep(0.2)\n", "rels = api.repos.list_releases()\n", "test_eq(len(rels), 1)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We can check that our file has been uploaded; GitHub refers to them as \"assets\":" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "assets = api.repos.list_release_assets(rels[0].id)\n", "test_eq(assets[0].name, 'README.md')" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "data": { "text/markdown": [ "---\n", "\n", "[source](https://github.com/fastai/ghapi/blob/master/ghapi/core.py#L160){target=\"_blank\" style=\"float:right; font-size:smaller\"}\n", "\n", "### GhApi.delete_release\n", "\n", "> GhApi.delete_release (release)\n", "\n", "Delete a release and its associated tag" ], "text/plain": [ "---\n", "\n", "[source](https://github.com/fastai/ghapi/blob/master/ghapi/core.py#L160){target=\"_blank\" style=\"float:right; font-size:smaller\"}\n", "\n", "### GhApi.delete_release\n", "\n", "> GhApi.delete_release (release)\n", "\n", "Delete a release and its associated tag" ] }, "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ "show_doc(GhApi.delete_release)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Branches and tags" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "@patch\n", "def list_tags(self:GhApi, prefix:str=''):\n", " \"List all tags, optionally filtered to those starting with `prefix`\"\n", " return self.git.list_matching_refs(f'tags/{prefix}')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "With no `prefix`, all tags are listed." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "test_eq(len(api.list_tags()), 1)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Using the full tag name will return just that tag." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "test_eq(len(api.list_tags(rel.tag_name)), 1)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "@patch\n", "def list_branches(self:GhApi, prefix:str=''):\n", " \"List all branches, optionally filtered to those starting with `prefix`\"\n", " return self.git.list_matching_refs(f'heads/{prefix}')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "Branches can be listed in the exactly the same way as tags." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "test_eq(len(api.list_branches('master')), 1)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "We can delete our release and confirm that it is removed:" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "api.delete_release(rels[0])\n", "test_eq(len(api.repos.list_releases()), 0)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "# See https://stackoverflow.com/questions/9765453\n", "EMPTY_TREE_SHA = '4b825dc642cb6eb9a060e54bf8d69288fbee4904'" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "# #|hide\n", "# #not working\n", "# #|export\n", "# @patch\n", "# def create_branch_empty(self:GhApi, branch):\n", "# c = self.git.create_commit(f'create {branch}', EMPTY_TREE_SHA)\n", "# return self.git.create_ref(f'refs/heads/{branch}', c.sha)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "@patch\n", "def create_branch_empty(self:GhApi, branch):\n", " t = self.git.create_tree(base_tree=EMPTY_TREE_SHA, tree = [dict(\n", " path='.dummy', content='ignore me', mode='100644', type='blob')])\n", " c = self.git.create_commit(f'create {branch}', t.sha)\n", " return self.git.create_ref(f'refs/heads/{branch}', c.sha)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "ref = api.create_branch_empty(\"testme\")\n", "test_eq(len(api.list_branches('testme')), 1)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "@patch\n", "def delete_tag(self:GhApi, tag:str):\n", " \"Delete a tag\"\n", " return self.git.delete_ref(f'tags/{tag}')" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "@patch\n", "def delete_branch(self:GhApi, branch:str):\n", " \"Delete a branch\"\n", " return self.git.delete_ref(f'heads/{branch}')" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "api.delete_branch('testme')\n", "test_eq(len(api.list_branches('testme')), 0)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "@patch\n", "def get_branch(self:GhApi, branch=None):\n", " branch = branch or self.repos.get().default_branch\n", " return self.list_branches(branch)[0]" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### Content (git files)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "@patch\n", "def list_files(self:GhApi, branch=None):\n", " ref = self.get_branch(branch)\n", " res = self.git.get_tree(ref.object.sha).tree\n", " return {o.path:o for o in res}" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "data": { "text/markdown": [ "```json\n", "{ 'mode': '100644',\n", " 'path': 'README.md',\n", " 'sha': 'eaea0f2698e76c75602058bf4e2e9fd7940ac4e3',\n", " 'size': 72,\n", " 'type': 'blob',\n", " 'url': 'https://api.github.com/repos/fastai/ghapi-test/git/blobs/eaea0f2698e76c75602058bf4e2e9fd7940ac4e3'}\n", "```" ], "text/plain": [ "{'path': 'README.md',\n", " 'mode': '100644',\n", " 'type': 'blob',\n", " 'sha': 'eaea0f2698e76c75602058bf4e2e9fd7940ac4e3',\n", " 'size': 72,\n", " 'url': 'https://api.github.com/repos/fastai/ghapi-test/git/blobs/eaea0f2698e76c75602058bf4e2e9fd7940ac4e3'}" ] }, "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ "files = api.list_files()\n", "files['README.md']" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "@patch\n", "def get_content(self:GhApi, path):\n", " res = self.repos.get_content(path)\n", " return base64.b64decode(res.content)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "readme = api.get_content('README.md').decode()\n", "assert 'ghapi' in readme" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "@patch\n", "def create_or_update_file(self:GhApi, path, message=None, content=None,\n", " sha=None, branch=None, committer=None, author=None):\n", " if not isinstance(content,bytes): content = content.encode()\n", " content = base64.b64encode(content).decode()\n", " kwargs = {'sha':sha} if sha else {}\n", " return self.repos.create_or_update_file_contents(path, message, content=content,\n", " branch=branch, committer=committer, author=author, **kwargs)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "@patch\n", "def create_file(self:GhApi, path, message, content=None, branch=None, committer=None, author=None):\n", " return self.create_or_update_file(path, message, content, branch=branch, committer=committer, author=author)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "res = api.create_file(\n", " path='foo',\n", " message=\"Create foo\",\n", " content=\"foobar\"\n", ")\n", "test_eq('foobar', api.get_content('foo').decode())" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "@patch\n", "def delete_file(self:GhApi, path, message, sha=None, branch=None, committer=None, author=None):\n", " if sha is None: sha = self.list_files()[path].sha\n", " return self.repos.delete_file(path, message=message, sha=sha,\n", " branch=branch, committer=committer, author=author)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "api.delete_file('foo', 'delete foo')\n", "assert 'foo' not in api.list_files()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "@patch\n", "def update_contents(self:GhApi, path, message, content, sha=None, branch=None, committer=None, author=None):\n", " if sha is None: sha = self.list_files()[path].sha\n", " return self.create_or_update_file(path, message, content,\n", " sha=sha, branch=branch, committer=committer, author=author)" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [ { "data": { "text/plain": [ "78" ] }, "execution_count": null, "metadata": {}, "output_type": "execute_result" } ], "source": [ "res = api.update_contents(\n", " path='README.md',\n", " message=\"Update README\",\n", " content=readme+\"foobar\"\n", ")\n", "res.content.size" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "readme = api.get_content('README.md').decode()\n", "assert 'foobar' in readme\n", "api.update_contents('README.md', \"Revert README\", content=readme[:-6]);" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "### GitHub Pages" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|export\n", "@patch\n", "def enable_pages(self:GhApi, branch=None, path=\"/\"):\n", " \"Enable or update pages for a repo to point to a `branch` and `path`.\"\n", " if path not in ('/docs','/'): raise Exception(\"path not in ('/docs','/')\")\n", " r = self.repos.get()\n", " branch = branch or r.default_branch\n", " source = {\"branch\": branch, \"path\": path}\n", " if r.has_pages: return # self.repos.update_information_about_pages_site(source=source)\n", " if len(self.list_branches(branch))==0: self.create_branch_empty(branch)\n", " return self.repos.create_pages_site(source=source)" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "`branch` is set to the default branch if `None`. `path` must be `/docs` or `/`." ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "res = api.enable_pages(branch='new-branch', path='/')\n", "\n", "test_eq(res.source.branch, 'new-branch')\n", "test_eq(res.source.path, '/')\n", "\n", "api.repos.delete_pages_site()\n", "api.delete_branch('new-branch')" ] }, { "cell_type": "markdown", "metadata": {}, "source": [ "## Export -" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [ "#|hide\n", "import nbdev; nbdev.nbdev_export()" ] }, { "cell_type": "code", "execution_count": null, "metadata": {}, "outputs": [], "source": [] } ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", "language": "python", "name": "python3" } }, "nbformat": 4, "nbformat_minor": 4 }