PythonでGithubから1MB以上のファイルを取得する[Github API, PyGithub]

はじめに

 こんにちは.最近カネコアヤノにハマっているはいばらです.いいぞ.
https://www.youtube.com/watch?v=_6rB7U-_yPg
 ところでGithubレポジトリごとcloneするのではなく,レポジトリ上のあるファイルだけをローカルで取得したい,という状況になったので,Python + Github APIで書いてみました.また,使用するAPIによって取得できるファイルのサイズの上限が1MBだったり100MBだったりもするので,その辺りについても解説します.

環境

$ uname -a
Linux LAPTOP-9NDJSK06 4.4.0-17763-Microsoft #379-Microsoft Wed Mar 06 19:16:00 PST 2019 x86_64 x86_64 x86_64 GNU/Linux
$ cat /etc/issue
Ubuntu 18.04.3 LTS \n \l
$ python3 -V
Python 3.6.8

今回作成したスクリプトとサンプルファイルを下記のレポジトリに置いておきましたので,適宜ご参照ください.
https://github.com/w-haibara/githubAPI-sample

以下のコマンドラインでの作業はこのレポジトリを適当な場所にcloneして,そこに移動した前提で進めていきます.

$ git clone https://github.com/w-haibara/githubAPI-sample.git
$ cd ./githubAPI-sample
$ ls
LICENSE  README.md  file  getFile.py  getLargeFile.py  largeFile

Github APIとは

Github APIGithubが提供するAPIで,レポジトリの作成・更新,issueの取得,PRの作成など,様々な機能が用意されています.日頃CUIでカタカタしたり,GUIでポチポチしたりしていたGithubの作業を自動化することがきて便利です.
Github APIの公式サイト↓
developer.github.com

PythonGithub APIを叩く(PyGithubの利用)

今回はPyGithubというライブラリを使って,PythonからGithub APIを叩きます.
PyGithub公式サイト↓
pygithub.readthedocs.io

APIとPyGithubのメソッドとの対応はこちら↓から確認できます.
APIs — PyGithub 1.53 documentation

Github API・PyGithubの導入についてはクラメソさんのブログ↓が分かりやすかったです.
dev.classmethod.jp

Repository Contents APIでファイルを取得する(1MBまで)

Github APIの中でも,Repository Contents APIを使っファイルを取得する方法を紹介します.これが一番単純な方法だと思いますが,取得できるファイルは1MBなことに注意しましょう.Repository Contents APIを使えば,レポジトリ内のファイルについての情報を取得できます.このレスポンスの中の「content」の項にはbase64エンコードされたファイルの中身が入っているので,それデコードすることで,ファイルの内容を取得できます.
PyGithubでは,Contents APIでのファイルの情報の取得はget_contents(path, ref=NotSet)が対応しています.このメソッドを使って以下のようなコードを書きました.

  • getFile.py
import base64
from github import Github

token = "******your_token******"
repository = "w-haibara/githubAPI-sample"
fileName = "file"

def get_file():

    g = Github(token)
    repo = g.get_repo(repository)
    contents = repo.get_contents(fileName)
    content = base64.b64decode(contents.content)

    with open("copy_" + fileName, mode="wb") as f:
        f.write(content)

    return("[succeed]")

def main():
        result = get_file()
        print(result)

if __name__ == "__main__":
    main()

「file」という名前の小さいファイルを取得して,「copy_file」という名前で保存します.

  • 実行結果
$ python3 getFile.py
[succeed]
$ ls
LICENSE    copy_file  getFile.py       largeFile
README.md  file       getLargeFile.py
$ diff copy_file file
$

間違いなくファイルを取得できていますね.

ここで,試しにfileではなくlargeFileを取得してみましょう.fileは9Bほどの小さいファイルですが,largeFileは2MBほどの大きさがあります.
fileName = "file"となっているところをfileName = "largeFile"と書き換えた上で再度実行してみます.

$ python3 getFile.py
Traceback (most recent call last):
~~~ (略) ~~~
github.GithubException.GithubException: 403 {'message': 'This API returns blobs up to 1 MB in size. The requested blob is too large to fetch via the API, but you can use the Git Data API to request blobs up to 100 MB in size.', 'errors': [{'resource': 'Blob', 'field': 'data', 'code': 'too_large'}], 'documentation_url': 'https://developer.github.com/v3/repos/contents/#get-contents'}

なにやらExceptionが表示されます.
This API returns blobs up to 1 MB in size. The requested blob is too large to fetch via the API, but you can use the Git Data API to request blobs up to 100 MB in size.
ということで,やはりContents APIでは1MBまでのファイルしか取得することができません.

Git Data APIでファイルを取得する(100MBまで)

こちらが本題です.Contents APIでは1MBまでのファイルしか取得できませんでしたが,Git Data Blobs APIを使えば100MBまでのファイルを取得することができます.しかし,Contents APIはレポジトリ名とパスでファイルを指定できたのに対して,Blobs APIではファイルのsha-1ハッシュで指定します.
いきなりハッシュを要求されて僕は頭に?が浮かんでしまったのですが,これはGItの内部動作で使われているものです.普段使うGitの磁器コマンド(add・commit・pushなど)では意識しませんが,磁器コマンドが内部で使用している配管コマンド(hash-object, cat-file, update-index, write-tree)ではレポジトリ内のファイルをsha-1ハッシュで指定します.Gitの内部では,レポジトリ内のファイルはGit Objectとして扱われ,そのGit Objectのキーとしてsha-1ハッシュが使われます.Blobs APIは,base64でデコードされたファイルの内容を含むblob(Binary Large Object)というタイプのGit Objectを扱うAPIなのでsha-1ハッシュが必要になります.
Git Objectについて↓
git-scm.com

ということで,Blobs APIを叩く前に,まずはファイルのハッシュを取得する必要があります.ファイルのハッシュはRepository Contents APIのレスポンスの「sha」で得られます.ここでRepository Contents APIでファイル自体を指定してしまうと,先ほどのように「1MB以上のファイルはgetできません!」と言われてしまうので,取得したいファイルが配置されているディレクトリを指定してAPIを叩きます.これはRepository Contents APIの仕様として,ディレクトリが指定されると,そのディレクトリ内のそれぞれのファイルのcontent以外の情報のarrayを返すことを利用しています.ディレクトリを指定すれば1MB以上のファイルであってもその他の情報を取得することができます.(「Response if content is a directory」の項を参照.Contents | GitHub Developer Guide)

PyGithubでは,Blobs APIでのblobの取得はget_git_blob(sha)が,Contents APIでのディレクトリ内のファイルの情報の取得はget_dir_contents(path, ref=NotSet) がそれぞれ対応しています.これらのメソッドを使って以下のようなコードを書きました.

  • getLargeFile.py
import base64
from github import Github

token = "******your_token******"
repository = "w-haibara/githubAPI-sample"
fileName = "largeFile"

def get_large_file():

    g = Github(token)
    repo = g.get_repo(repository)
    dir_contents = repo.get_dir_contents("/")

    sha = 0
    
    for i in range(len(dir_contents)):
        if dir_contents[i].name == fileName:
            sha = dir_contents[i].sha

    if sha == 0:
        return("[failed] File Not Found")

    blob = repo.get_git_blob(sha)
    content = base64.b64decode(blob.content)

    with open("copy" + fileName, mode="wb") as f:
        f.write(content)

    return("[succeed]")

def main():
        result = get_large_file()
        print(result)

if __name__ == "__main__":
    main()
  • 実行結果
$ python3 getLargeFile.py
[succeed]
$ ls
LICENSE    copy_file       file        getLargeFile.py
README.md  copy_largeFile  getFile.py  largeFile
$ diff copy_largeFile largeFile
$

1MB以上のファイルも取得することができました✨

おわりに

Gitの勉強にもなって面白かった(小並感)
Gitの磁器コマンドと配管コマンドの話題が出ましたが,磁器コマンドの使用を禁止するコマンドの記事が先日Qiitaに上がっていましたね.これで配管コマンドマスターを目指しましょう!
qiita.com