Github OpenAPI ============== Adding classes, attributes and methods -------------------------------------- Github provides an `OpenAPI specification for its v3 REST API `__. This can be used to semi-automate the creation and maintenance of PyGithub classes. This allows for :ref:`adding attributes ` and :ref:`adding methods ` to PyGithub classes, or :ref:`create entire PyGithub classes `, including preliminary tests. The created classes and tests serve as a foundation to bootstrap the implementation phase for new functionality. It automates conventions and code style of this code base. It nevertheless requires the developer to review and refactor the generated code to achieve working implementation. OpenAPI annotations ------------------- PyGithub classes have annotations that link the code to the OpenAPI spec. This allows to automate syncing the implementation with the specification. PyGithub class annotations ~~~~~~~~~~~~~~~~~~~~~~~~~~ PyGithub classes have annotations that link those classes to the respective schemas of the OpenAPI spec. For example, the ``Repository`` class has this header: .. code-block:: python class Repository(CompletableGithubObject): """ This class represents Repositories. The reference can be found here https://docs.github.com/en/rest/reference/repos The OpenAPI schema can be found at - /components/schemas/event/properties/repo - /components/schemas/full-repository - /components/schemas/minimal-repository - /components/schemas/nullable-repository - /components/schemas/pull-request-minimal/properties/base/properties/repo - /components/schemas/pull-request-minimal/properties/head/properties/repo - /components/schemas/repository - /components/schemas/simple-repository """ The list of OpenAPI schemas can be found below the ``The OpenAPI schema can be found at`` line. .. _get-openapi-schema: A schema can easily be extracted from the OpenAPI spec as follows (this requires `jq `__ to be installed):: ./scripts/get-openapi-schema.sh "/components/schemas/minimal-repository" < api.github.com.2022-11-28.json This outputs:: { "title": "Minimal Repository", "description": "Minimal Repository", "type": "object", "properties": { "id": { "type": "integer", "format": "int64", "example": 1296269 }, "node_id": { "type": "string", "example": "MDEwOlJlcG9zaXRvcnkxMjk2MjY5" }, "name": { "type": "string", "example": "Hello-World" }, "full_name": { "type": "string", "example": "octocat/Hello-World" }, … } PyGithub method annotations ~~~~~~~~~~~~~~~~~~~~~~~~~~~ Methods of PyGithub classes are annotated with the API path that they call. For example, the ``get_branch`` method of the ``Repository`` class has this header: .. code-block:: python def get_branch(self, branch: str) -> Branch: """ :calls: `GET /repos/{owner}/{repo}/branches/{branch} `_ :param branch: string :rtype: :class:`github.Branch.Branch` """ This documents that the method calls the ``/repos/{owner}/{repo}/branches/{branch}`` API path using the ``GET`` verb. .. _get-openapi-path: A path can easily be extracted from the OpenAPI spec as follows (this requires `jq `__ to be installed):: ./scripts/get-openapi-path.sh "/repos/{owner}/{repo}/branches/{branch}" < api.github.com.2022-11-28.json This outputs:: { "get": { "summary": "Get a branch", "description": "", "tags": ["repos"], "operationId": "repos/get-branch", "externalDocs": { "description": "API method documentation", "url": "https://docs.github.com/rest/branches/branches#get-a-branch" }, "parameters": […], "responses": { "200": { "description": "Response", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/branch-with-protection" }, "examples": { "default": { "$ref": "#/components/examples/branch-get" } } } } }, "301": { "$ref": "#/components/responses/moved_permanently" }, "404": { "$ref": "#/components/responses/not_found" } }, … } } The OpenAPI sync CLI -------------------- The main script to leverage the OpenAPI spec is the ``scripts/openapi.py`` CLI. Run ``python scripts/openapi.py --help`` or ``python scripts/openapi.py COMMAND --help`` for help:: usage: openapi.py [-h] [--dry-run] [--exit-code] [--verbose] {fetch,index,suggest,apply,create} ... Applies OpenAPI spec to PyGithub GithubObject classes positional arguments: {fetch,index,suggest,apply,create} options: -h, --help show this help message and exit --dry-run Show prospect changes and do not modify any files (default: False) --exit-code Indicate changes via non-zeor exit code (default: False) --verbose Provide more information (default: False) Most commands support the ``--dry-run`` option. This will not modify any files but show prospect code changes. Setup OpenAPI support --------------------- Download the OpenAPI specification, e.g. version ``2022-11-28`` for the ``api.github.com`` API:: python scripts/openapi.py fetch api.github.com 2022-11-28 api.github.com.2022-11-28.json Load the PyGithub sources into an index file, e.g. ``openapi.index``:: python scripts/openapi.py index github api.github.com.2022-11-28.json openapi.index Automatically add schemas to PyGithub classes --------------------------------------------- The ``openapi.py`` script can suggest OpenAPI schemas for PyGithub classes. Suggest schemas:: python scripts/openapi.py suggest schemas api.github.com.2022-11-28.json openapi.index Commit Add suggested schemas:: python scripts/openapi.py suggest schemas --add api.github.com.2022-11-28.json openapi.index Commit This may produce the following changes:: diff --git a/github/Commit.py b/github/Commit.py index 7a2ac9d0..2ae31d07 100644 --- a/github/Commit.py +++ b/github/Commit.py @@ -89,6 +89,7 @@ class Commit(CompletableGithubObject): The OpenAPI schema can be found at - /components/schemas/branch-short/properties/commit + - /components/schemas/commit - /components/schemas/commit-search-result-item/properties/parents/items - /components/schemas/commit/properties/parents/items - /components/schemas/short-branch/properties/commit Once new schemas have been added to classes, these schemas should be applied next. Only applying the schemas will add new attributes to the class. .. _apply-schemas: Automatically add attributes to PyGithub classes ------------------------------------------------ After new schemas have been added to PyGithub classes, or a new OpenAPI spec has been downloaded, the schemas can be applied to PyGithub classes as follows. Applying a schema to a PyGithub class adds all missing attributes to the PyGithub class as defined by the schema. First update the index, then apply the schemas (here to class ``Commit`` only):: python scripts/openapi.py index github api.github.com.2022-11-28.json openapi.index python scripts/openapi.py apply --tests --new-schemas create-class github api.github.com.2022-11-28.json openapi.index Commit This may produce the following changes:: diff --git a/github/Commit.py b/github/Commit.py index 84cb78eb..2ae31d07 100644 --- a/github/Commit.py +++ b/github/Commit.py @@ -100,6 +100,7 @@ class Commit(CompletableGithubObject): def _initAttributes(self) -> None: self._author: Attribute[NamedUser] = NotSet self._comments_url: Attribute[str] = NotSet + self._commit: Attribute[GitCommit] = NotSet self._committer: Attribute[NamedUser] = NotSet self._files: Attribute[list[File]] = NotSet self._html_url: Attribute[str] = NotSet @@ -128,6 +129,11 @@ class Commit(CompletableGithubObject): self._completeIfNotSet(self._comments_url) return self._comments_url.value + @property + def commit(self) -> GitCommit: + self._completeIfNotSet(self._commit) + return self._commit.value + @property def committer(self) -> NamedUser: self._completeIfNotSet(self._committer) @@ -332,6 +338,8 @@ class Commit(CompletableGithubObject): self._author = self._makeClassAttribute(github.NamedUser.NamedUser, attributes["author"]) if "comments_url" in attributes: # pragma no branch self._comments_url = self._makeStringAttribute(attributes["comments_url"]) + if "commit" in attributes: # pragma no branch + self._commit = self._makeClassAttribute(github.GitCommit.GitCommit, attributes["commit"]) if "committer" in attributes: # pragma no branch self._committer = self._makeClassAttribute(github.NamedUser.NamedUser, attributes["committer"]) if "files" in attributes: # pragma no branch With option ``--tests``, tests will also be modified. Some attributes may return schemas that are not implemented by any PyGithub class. In that case, option ``--new-schemas create-class`` creates all those classes. .. _create-class: Create a PyGithub class from an OpenAPI schema ---------------------------------------------- Note: PyGithub classes can be created automatically where needed using ``--new-schemas create-class`` when :ref:`applying schemas ` or :ref:`creating methods `. PyGithub classes can be created based on a Github OpenAPI schema. However, it is easier to start from a Github REST API path. Given a Github REST API path like ``/app``, you can extract the ``GET`` response from the OpenAPI spec via:: ./scripts/get-openapi-path.sh "/app" < api.github.com.2022-11-28.json The JSON path ``'.get.responses."200".content'`` provides details about the response schema:: ./scripts/get-openapi-path.sh "/app" < api.github.com.2022-11-28.json | jq '.get.responses."200".content' { "application/json": { "schema": { "$ref": "#/components/schemas/integration" }, … } } A new PyGithub can be created from an OpenAPI schema as follows. First, update the index, then create the class:: python scripts/openapi.py index github api.github.com.2022-11-28.json openapi.index python scripts/openapi.py create class --tests --new-schemas create-class \ github api.github.com.2022-11-28.json openapi.index \ AuthenticatedApp https://docs.github.com/en/rest/reference/apps#get-the-authenticated-app \ /components/schemas/integration The Github docs URL (in above example ``https://docs.github.com/en/rest/reference/apps#get-the-authenticated-app``) can be obtained from the OpenAPI spec via JSON path ``'.get.externalDocs.url'``:: ./scripts/get-openapi-path.sh "/app" < api.github.com.2022-11-28.json | jq '.get.externalDocs.url' "https://docs.github.com/rest/apps/apps#get-the-authenticated-app" This would create the following PyGithub class (``github/AuthenticatedApp.py``):: ############################ Copyrights and license ############################ … ################################################################################ from __future__ import annotations from typing import Any, TYPE_CHECKING from datetime import datetime, timezone import github.NamedUser from github.GithubObject import NonCompletableGithubObject from github.GithubObject import Attribute, NotSet if TYPE_CHECKING: from github.GithubObject import NonCompletableGithubObject from github.NamedUser import NamedUser class AuthenticatedApp(NonCompletableGithubObject): """ This class represents AuthenticatedApp. The reference can be found here https://docs.github.com/en/rest/reference/apps#get-the-authenticated-app The OpenAPI schema can be found at - /components/schemas/integration """ def _initAttributes(self) -> None: self._client_id: Attribute[str] = NotSet self._created_at: Attribute[datetime] = NotSet … self._owner: Attribute[NamedUser] = NotSet self._slug: Attribute[str] = NotSet self._updated_at: Attribute[datetime] = NotSet def __repr__(self) -> str: # TODO: replace "some_attribute" with uniquely identifying attributes in the dict, then run: return self.get__repr__({"some_attribute": self._some_attribute.value}) @property def client_id(self) -> str: return self._client_id.value @property def created_at(self) -> datetime: return self._created_at.value @property def owner(self) -> NamedUser: return self._owner.value @property def slug(self) -> str: return self._slug.value @property def updated_at(self) -> datetime: return self._updated_at.value def _useAttributes(self, attributes: dict[str, Any]) -> None: # TODO: remove if parent does not implement this super()._useAttributes(attributes) if "client_id" in attributes: # pragma no branch self._client_id = self._makeStringAttribute(attributes["client_id"]) if "created_at" in attributes: # pragma no branch self._created_at = self._makeDatetimeAttribute(attributes["created_at"]) … if "owner" in attributes: # pragma no branch self._owner = self._makeClassAttribute(github.NamedUser.NamedUser, attributes["owner"]) if "slug" in attributes: # pragma no branch self._slug = self._makeStringAttribute(attributes["slug"]) if "updated_at" in attributes: # pragma no branch self._updated_at = self._makeDatetimeAttribute(attributes["updated_at"]) As well as the following PyGithub test class (``tests/AuthenticatedApp.py``):: ############################ Copyrights and license ############################ … ################################################################################ from __future__ import annotations from datetime import datetime, timezone from . import Framework class AuthenticatedApp(Framework.TestCase): def setUp(self): super().setUp() # TODO: create an instance of type AuthenticatedApp and assign to self.aa, then run: # pytest ./tests/AuthenticatedApp.py -k testAttributes --record # ./scripts/update-assertions.sh ./tests/AuthenticatedApp.py testAttributes # pre-commit run --all-files self.aa = None def testAttributes(self): aa = self.aa self.assertEqual(aa.__repr__(), "") self.assertEqual(aa.client_id, "") self.assertEqual(aa.created_at, datetime(2020, 1, 2, 12, 34, 56, tzinfo=timezone.utc)) … self.assertEqual(aa.slug, "") self.assertEqual(aa.updated_at, datetime(2020, 1, 2, 12, 34, 56, tzinfo=timezone.utc)) First complete the ``setUp`` method like:: def setUp(self): self.authMode = "app" # usually not needed super().setUp() self.aa = self.g.get_app() # the method that returns the tested class Next, record test data for the ``testAttributes`` test method:: pytest ./tests/AuthenticatedApp.py -k testAttributes --record You will see ``AssertionError`` because the assertions in ``testAttributes`` do not match the recorded data. So update the expected values:: ./scripts/update-assertions.sh tests/AuthenticatedApp.py testAttributes Once all assertions are updated, you can run the new test class:: pytest tests/AuthenticatedApp.py .. _create-method: Create a PyGithub method from an OpenAPI path --------------------------------------------- Note: Creating methods is not fully implemented. However, the create code is a good starting point. Methods can be added to PyGithub classes via the ``scripts/openapi.py`` script. First update the index, then create a method:: python scripts/openapi.py index github api.github.com.2022-11-28.json openapi.index python scripts/openapi.py create method --new-schemas create-class \ api.github.com.2022-11-28.json openapi.index \ AuthenticatedApp get_installations GET /app/installations Adds the method ``get_installations`` to ``github/AuthenticatedApp.py``:: def get_installations(self) -> list[Installation]: """ :calls: `GET /app/installations `_ :rtype: list[github.Installation.Installation] List installations for the authenticated app. """ headers, data = self._requester.requestJsonAndCheck("GET", f"{self.url}/installations") return data