GoogleAppsScript と Python の連携(Python calls GAS Functions)

PythonからGAS(Google Apps Script)関数を呼び出すためのメモ。

目標

GAS関数を利用してGoogleAPIを一本化しよう!

下準備

読者がどれくらいGoogleサービスに慣れているかわからないが、一から全てを記述していては日が暮れてしまうため、以下の項目については各自でGoogleしてほしい。

GoogleAppsScriptについて

  • Google Apps Scriptプロジェクトの作成方法
  • claspによるGoogle Apps Scriptのローカル開発
  • Google Cloud Platformのプロジェクト作成方法
  • Google Cloud PlatformでAPIを使用するためのOAuth2.0クライアントIDの取得
  • Google Apps Scriptのプロジェクトを実行可能APIとしてデプロイする

きっと良い記事が見つかることだろう。

以下ではGASでsendEmail()という次の関数を実行可能APIとしてデプロイしたと仮定して進める。

/* これはTypeScriptで書いているため、GASで動かすためには型を取り除く必要がある */
function sendEmail(recipient: string, subject: string, body: string) {
  MailApp.sendEmail(
    recipient,
    subject,
    body
  )
}

Python から GAS関数を実行する

ライブラリのインストール

Googleが提供しているOAuth2.0およびAPIを呼び出しやすくするためのライブラリをインストールする。

$ pip install google-auth google-auth-oauthlib google-api-python-client

よくチュートリアルにあるoauth2clientdeprecatedとなっている(accessed on 2021/12/4)ため使用しない。Googleもドキュメントを書き換えるべきだが忙しいのだろうか。

Credentialsの取得

よくわからなくてもコピペすれば動くのがチュートリアルコードであるが簡単に解説すると、

  • Credentialsはユーザが同意したGoogleリソースにアクセスするための資格情報である
  • Credentialsはファイルに保存して再利用することが出来る
  • Credentialsが期限切れでリフレッシュトークンがある場合はリフレッシュできる
  • リフレッシュトークンがない場合やそもそもCredentialsが存在しない場合はOAuth2.0フローを通して資格情報を取得する
  • scopeとはアプリケーションが利用するGoogleリソースの範囲・権限である

client_secret.jsonGoogle Cloud PlatformAPIとサービス→認証情報→OAuth2.0クライアントというところから取得する(ググって)

import os
from typing import Optional

from google.auth.transport.requests import Request
from google.oauth2.credentials import Credentials
from google_auth_oauthlib.flow import InstalledAppFlow

CLIENT_SECRET_FILE = 'client_secret.json'
TOKEN_FILE = 'token.json'
SCOPES = [
    'https://www.googleapis.com/auth/script.scriptapp'
    # 今回はメールを送信する関数しか呼び出さないのでこのスコープだけ
    'https://www.googleapis.com/auth/script.send_mail' 
]

def get_credentials() -> Credentials:
    credentials: Optional[Credentials] = None
    if os.path.exists(TOKEN_FILE):
        credentials = Credentials.from_authorized_user_file(TOKEN_FILE, scopes=SCOPES)
    if credentials and credentials.valid:
        return credentials

    if credentials and credentials.refresh_token:
        credentials.refresh(Request())
        # リフレッシュトークンが期限切れのこともあるのでエラーハンドリングすべき
        return credentials
        
    flow = InstalledAppFlow.from_client_secrets_file(
        CLIENT_SECRET_FILE,
        scopes=SCOPES
    )
    credentials = flow.run_console()
    return credentials


def save_credentials(credentials: Credentials) -> None:
    with open(TOKEN_FILE, 'w') as f:
        f.write(credentials.to_json())

Credentialsを取得するフローはAPIを叩くなら必ず必要なので、ライブラリにするかスニペットに登録するか記憶に叩き込む。

GAS関数を呼び出す

準備が整ったのでGASの関数を呼び出す関数を実装する。

from typing import Any
from googleapiclient import discovery

GAS_DEPLOY_ID = 'hogehoge'  # Google Apps Script の実行可能APIのデプロイID

def call_function(
    func_name: str, 
    parameters: Optional[list[str]] = None
) -> Any:
    body: dict[str, Any] = {
        'function': func_name,
    }
    if parameters:
        body['parameters'] = parameters

    script_service = discovery.build(
        'script',
        'v1',
        credentials=get_credentials()
    )
    response: Any = script_service.scripts().run(
        scriptId=GAS_DEPLOY_ID,
        body=body
    ).execute()
    
    # JavaScriptのオブジェクトをJSON化してPythonのdictにしたものが返ってくる
    return response.get('response').get('result') 

googleapiclientライブラリの使い方は3つくらいのAPIのドキュメントを読めば共通点がつかめる。

  • googleapiclient.discovery.build()で使用するAPIインスタンスを作成
  • インスタンスにメソッドチェーンでAPIのパスを構築
  • 引数はドキュメントを読みながら実装
  • 最後にexecute()メソッドを呼び出してレスポンスを受け取る
  • レスポンスの中身もドキュメントを見ないとわからない

developers.google.com (accessed on 2021/12/4)

最後にcall_function()を使用してGASのsendEmail()を呼び出してみる。

recipient = 'foo@example.com'
subject = 'Test Calling GAS function'
body = 'Success!'
parameters = [recipient, subject, body]
call_function(func_name='sendEmail', parameters=parameters)

無事メールが送信されれば成功。

応用案

今回はメールを送信するだけだったので直接GmailAPIを叩いてもよかった。 しかし、一度GASを通すという今回の実装はGoogleの複数のAPIを利用するときに真価を発揮する。

例えば次のような一連の処理

Python上で実装すると3回HTTP通信を行わなければならない。GASで実装すれば一つのAPI呼び出しだけで終わる。

「全部GAS上でやれば?」というのはもっともな指摘であるが、今回は処理にSeleniumをかませる必要があったのでGAS環境外のリソースを使う必要があったのだ。

GASについて

Google Apps ScriptはGoogleのリソースを使ってスクリプトを実行できるサービスである。 ベースにはJavaScriptが採用されていて、claspというGoogle製のコマンドラインツールを使用すればローカル環境でTypeScriptを使って開発することが出来る。 1日に使用できるAPI呼び出しや外部アクセスに対して制限(accessed on 2021/12/4)が存在するが、個人で使用する分や小規模サービスであればそれほど問題にはならない。

github.com (accessed on 2021/12/4)

Google Apps Scriptを使用する理由として以下のが挙げられる。

  1. 他のGoogleサービスとの連携が容易
  2. Googleのリソースを使用することができる
  3. ベースがJavaScriptなので気軽に始められる

GoogleAPI提供に積極的であるため、どんな言語(Node.js, Python, Go, C#, 単純にHTTPリクエスト)でも容易にAPIを叩くことが出来る。GmailGoogle SpreadSheetsGoogle Driveを連携したシステムを構築したいとなった際、これらの言語で各APIを叩くこともできるが、GAS上で各サービスのAPIを呼び出してGoogleの通信リソースを借用することで自身の通信リソースを節約することが出来る。

おわりに

PythonからGoogle APIの呼び出しをGASを使って一本化するというのは、本当はclaspを使ってTypeScriptでがっつりGASのローカル開発環境を整えてからでないと意味がない。 しかしながら、自分はまだTypeScript初心者であるし(jestもやっとのことで導入できた。。。)、先人がいくつか記事を残してくれていたので今回は詳しく触れないことにする(記憶用の簡単なメモは書くかもしれない)。この記事を読んで「claspなんてあるのか」とか「google-authなんてライブラリがあったのか」と思ってGoogleするきっかけになれば私も嬉しく思う。

このようにGoogleAPIと自社のリソースをエンドユーザにさえ積極的に公開して、世の中のAutomationや業務効率化が進むように貢献している。 それに比べて日本の企業は「我が社にすべてお任せください!」という姿勢ばかりで、自社のリソースをエンドユーザに公開することなど一切ない。

先日MLサービスを比較しようとして、どことは言わないが日本の企業のサイトも見に行ってみた。 驚いたことにサービスの紹介ばかりでチュートリアルなどは一切なく、最後に「資料請求はこちらから」と書いてあったのは爆笑してしまった。 完全に組織のネームバリューに依存したマーケティング戦略で、ユーザにサービスをお試しで使ってもらって競合サービスとの差を実感してもらおうという姿勢が全く見られない。

10か月前ほどにGooglemodel_searchという機械学習のハイパーパラメータを遺伝的(mutation)アルゴリズムによって最適化するライブラリを公開した。

github.com (accessed on 2021/12/4)

Apache2.0ライセンスのもと使用できるオープンソースプロジェクトである。コントリビュータはたったの2人! ただでさえ重いニューラルネットワーク学習タスクに遺伝的アルゴリズムを加えて計算機パワーでAIエンジニアの仕事を奪う楽にするという素晴らしいライブラリだ。 もちろんこんなものエンドユーザのローカル環境で動かせるはずもなく、中小企業でも難しいと思われる。 当然Googleはビジネスが上手いので、自社のクラウドコンピューティング環境を実行環境として提供している。

  1. ライブラリを公開して社会が潤う
  2. ライブラリの使用にクラウドが必要なのでGoogleも潤う
  3. 潤った資源を研究者に配分して新技術をライブラリとして公開

という社会も企業も研究者も得するサイクルがそこには存在する。 日本の企業で新技術をライブラリとして公開しているとかLinuxディストリビューションを頒布しているとかあったら教えてください。就活の参考にします。 脆弱性対応の分野では結構進んでいる方だと思うけれど、脆弱性潰しても儲からん。。。

確か先月あたりにMicrosoftもAutoMLのライブラリを公開していた。名前は忘れた。

Pythonパッケージ開発フロー

今日は半日でパッケージのα版を一つリリースして、デプロイまで実施できた。

パッケージ開発のフローにだいぶ慣れてきた証拠とも言えるので、それをメモしておこう。

作成したパッケージ

https://github.com/hinihat/backupGDgithub.com

特定のファイルまたはディレクトリの.tar.gz形式の圧縮ファイルを作成して、それをGoogleDriveにアップロードするというもの。

実質ファイル一つ、クラス一つの極小パッケージであるが、cronやscheduleライブラリで定期的に実行することでサーバー内のデータをバックアップしておくことができる。

予めGoolgeCloudPlatformを操作してGoogleDriveAPIを叩けるようにしておく必要がある。

import backupGD

tarfile = backupGD.create_backup('./data') # Create gzipped tar file
with backupGD.drive('client_secret.json') as drive:
    folder_id = drive.create_folder('backupGD') # Create folder on Google Drive
    drive.upload_backup(tarfile, folder_id=folder_id) # Upload tar file

このパッケージの作成手順を追っていく。

メインパッケージの作成

まず最初にやるのはメインパッケージ作成およびインターフェースの設計だ。

メインパッケージとはimport XXXfrom XXX import YYYXXXの部分のことで、ユニークかつ簡潔な名前が求められる。

googleという文字を入れてしまうと公式パッケージと紛らわしくなるのでGoogleDriveをGDと省略することにした。

インターフェースの設計

ここら辺はまだベストプラクティスを探索中である。

現在はクラスベースでメソッドによってインターフェースを提供することが多いが、import numpy as npimport pandas as pdのような省略形があるなら関数ベースのインターフェースでも良いのかもしれない。 ただ個人のパッケージでそのような省略形を他のパッケージをバッティングしないように設定するのは難しいのではと思っている。

クラスベースの良いところは

  • カプセル化によって状態を保持できる
  • ユーザーが意識しなくて良い値をユーザーから隠すことができる
  • 複数のインスタンスを同じコード内で使用できる

今回の場合、究極的には

import backupGD

backupGD.backup('./data', client_secret='client_secret.json')

だけで良い。(記事を書き終わった後にこのインターフェースが採用された)

でもcreate_folder()とかdelete_folder()とかを後々追加した際に、そこでも引数にclient_secretを渡さないといけなくなるので、結局プロパティを使いまわせるクラスベースのほうが良いのかな。

Docstringの整備

作成したインターフェースに対してDocstringを付与していく。 これがテストコードや自動生成ドキュメントの原型となる。

Numpy StyleのDocstringの例

def add(a: int, b: int) -> int:
    """Add two integers

    Parameters
    ------------
    a : int
       integer
    b : int
       other integer
    
    Returns
    --------
    int
       Return `a` + `b`
    """
    return a + b

nnt339es.hatenablog.com

テストコードの実装

最低限の動作保証をするユニットテストを実装。

とりあえず期待する入力に対して期待する出力をテストしておけば、リファクタリングで問題が発生した時もトラブルシューティングが楽になる。

がっつり書くなら期待しない引数入力に対する例外送出もテストする。 Pythonの型ヒントは飽くまで"型ヒント"なので引数の型が異なってもインタプリターが処理できるならそのまま処理してしまう。 これは適当に書いても動作するので便利だが、一方でバグの発見が遅れる原因にもなる。

そもそも「型ヒントを付けているのにそれを無視して引数を渡すユーザーが悪い」ということであまり厳密にテストはしていない。

ただし、__init__()でプロパティを初期化するときは、後々呼び出すメソッドがエラーを吐かないように厳密に引数をチェックすべきである。そうしなければバグの発見が遅れることになる。

そういえばユニットテストに関する記事を一つも書いていなかったな。

setup.pyその他

setup.py, setup.cfg,README.md,LICENSE等書いてパッケージ化を進める。

詳細は省略。

主流はpyproject.tomlか。

ドキュメントの生成

sphinxでドキュメントを自動生成

$ pip install sphinx sphinx-rtd-theme m2r2

クイックスタート

$ sphinx-quickstart docs_src

conf.py

# Configuration file for the Sphinx documentation builder.
#
# This file only contains a selection of the most common options. For a full
# list see the documentation:
# https://www.sphinx-doc.org/en/master/usage/configuration.html

# -- Path setup --------------------------------------------------------------

# If extensions (or modules to document with autodoc) are in another directory,
# add these directories to sys.path here. If the directory is relative to the
# documentation root, use os.path.abspath to make it absolute, like shown here.
#
import os
import sys
sys.path.insert(0, os.path.abspath('..'))


# -- Project information -----------------------------------------------------

project = 'backupGD'

# The full version, including alpha/beta/rc tags
import backupGD
release = backupGD.__version__


# -- General configuration ---------------------------------------------------

# Add any Sphinx extension module names here, as strings. They can be
# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
# ones.
extensions = [
    'sphinx.ext.autodoc',
    'sphinx.ext.napoleon',
     'sphinx_rtd_theme',
  ]

# Add any paths that contain templates here, relative to this directory.
templates_path = ['_templates']

# List of patterns, relative to source directory, that match files and
# directories to ignore when looking for source files.
# This pattern also affects html_static_path and html_extra_path.
exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']


# -- Options for HTML output -------------------------------------------------

# The theme to use for HTML and HTML Help pages.  See the documentation for
# a list of builtin themes.
#
html_theme = 'sphinx_rtd_theme'

# Add any paths that contain custom static files (such as style sheets) here,
# relative to this directory. They are copied after the builtin static files,
# so a file named "default.css" will overwrite the builtin "default.css".
html_static_path = ['_static']

source_suffix = ['.rst', '.md']

ビルド

$ sphinx-apidoc -f -o ./docs_src backupGD
$ sphinx-build docs_src docs

Git

$ git init # リポジトリ初期化
$ git add .
$ git commit -m "Initial commit" # コミット
$ git remote add origin XXX # origin 追加
$ git push -u origin master # プッシュ

あとはGitHub Pagesでドキュメント公開したりPyPIにアップロードしたりDockerイメージ作ったり。

Python(Go)でリバースプロキシを実装

リバースプロキシをライブラリに組み込む必要があったのでそのメモ。

proxy.py

github.com (accessed on 2021/12/10)

リバースプロキシなんて誰かが作ってるだろ、と探したらproxy.pyに行きついた。

高速・軽量・拡張性・安全性その他もろもろが売りらしい。

Pythonのプロキシライブラリは意外に少なくてwebsocketにも対応してるのがこれくらいだった。(選択理由)

使ってみると確かに拡張性が良く、Pluginを導入するだけで広告ブロックからリバースプロキシまで様々な機能を追加することができる。 継承して必要な処理だけオーバーライドすればカスタムプラグインも容易に作成できる。

結論からいうと、このライブラリは使用しなかった。

理由はまだ実装途中らしく、パッチを当てなければリバースプロキシとして使えないからだ。 軽くソースコードを書き換えてHTTPリクエストはハンドルできるようになったが、Websocketの方がうまく実装できずこのライブラリを使用することを諦めた。

期待はできそうなのでGitHubでスターは付けておいた。

Go

他に良さそうなライブラリがなかったので他の言語で実装することを考えた。

候補はいろいろあるが最初に挙がるのはやはりGois Godである。

JavaScriptPythonと同程度に遅いから使う必要が感じられないし、C/C++での実装は煩雑になりそうなのでNG。 Juliaはコミュニティが小さいので文献を探すのが大変で、Rもまあ似たような理由。

というわけでコミュニティがデカくて、高速なコンパイル言語Goから検索を開始したのである。

検索してびっくり仰天!なんとプロキシを標準ライブラリがサポートしているのである。

pkg.go.dev

しかも実装が非常に単純!

package main

import (
    "log"
    "net/url"
    "net/http"
    "net/http/httputil"
)

func main() {
    loc := "/go/is/god"
    proxyPass := "http://localhost:10000"
 
    url, err := url.Parse(proxyPass)
    if err != nil {
        log.Fatal(err)
    }
    http.Handle(loc, httputil.NewSingleHostReverseProxy(url))
    http.ListenAndServe()
}

これだけでNginx

location /go/is/god {
   proxy_pass http://localhost:10000
}

に相当する処理を実装出来て、必要なヘッダーもインジェクトしてくれる。もちろんWebsocket対応。

あとはこれをProgramableに書き換えて、こんな感じ。

package proxy

import (
    "fmt"
    "log"
    "net/http"
    "net/http/httputil"
    "net/url"
)


type location struct {
    Loc string
    ProxyPass *url.URL
}


type ReverseProxy struct {
    locs []location
}


func NewReverseProxy() *ReverseProxy {
    locs := []location{}
    return &ReverseProxy{locs}
}


func (rp *ReverseProxy) AddLocation(loc, proxyPass string) error {
    target, err := url.Parse(proxyPass)
    if err != nil {
        return err
    }
    rp.locs = append(rp.locs, location{Loc: loc, ProxyPass: target})
    return nil
}


func (rp * ReverseProxy) ServeForever(host string, port int) {
 
    addr := fmt.Sprintf("%s:%d", host, port)
    mux := http.NewServeMux()
    for _, loc := range rp.locs {
        mux.Handle(loc.Loc, httputil.NewSingleHostReverseProxy(loc.ProxyPass))
    }
    srv := &http.Server{
        Addr:    addr,
        Handler: mux,
    }

    go func() {
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatalln(err)
        }
    }()
}

Go is God

gopy

GoソースからCPythonバンディングソースを自動生成するプロジェクトである。

github.com

以前Cythonでやった”アレ”だ。

nnt339es.hatenablog.com

ライブラリを使用

go-extensionというライブラリを作成したので紹介する。 私の試行錯誤の結果をライブラリとしてまとめ、面倒な作業を行わずにビルド/配布を可能とする。

github.com

必要条件

Go1.16以上

インストール方法

pip install go-extension

使用方法

GoExtensionというクラスのインスタンスsetup()ext_modules引数に渡すだけ。

setup.py

from setuptools import setup
try:
    from go_extension import GoExtension
except ImportError:
    from pip._internal.cli import main
    main.main(['install','-U','go-extension'])
    from go_extension import GoExtension


ext = GoExtension(
    name='hello_go',
    packages=['github.com/hinihatetsu/go-extension-python/hello']
)

setup(
    ext_modules=[ext]
)

ビルドコマンド

python setup.py build_ext --inplace

ライブラリを使用しない

以下はライブラリ作成前の試行錯誤の過程である。

必要条件

Go1.15以上

  • pybindgen
  • goimports

ということで、依存ライブラリごとインストールする。

pip install pybindgen
go get golang.org/x/tools/cmd/goimports
go get go get github.com/go-python/gopy

ビルド

いろいろ研究した結果、

gopy build -name=go -no-make -rename -vm=python -output=<output> <pkg> 

が良さそうなビルドコマンドだと思われる。

  • -name=go : 無くてもよいが、名前をわかりやすくするため。
  • -no-make : Makefileが生成されなくなる。
  • rename : GoのパスカルケースをPythonのスネークケースに変換してくれる。
  • vm=python : Pythonの実行可能コマンド。python, python3など複数ある場合に有効。デフォルトがpython3
  • output=<output> : 出力先のディレクトリ。このディレクトリがそのままPythonパッケージになる。
  • <pkg> : Goのパッケージ名(複数可)。例 : github.com/hinihatetsu/hogehoge

例えば、次のようなパッケージを考える。

tree /f
│  go.mod
│  setup.py
│
├─go_pkg
│      main.go
│
└─py_pkg
        __init__.py

ルートがPythonのプロジェクトディレクトリであり、かつGoのプロジェクトディレクトリ。

go_pkgPythonのパッケージpy_pkg/goとしてビルドしたいならば、

gopy build -name=go -no-make -rename -vm=python -output=py_pkg/go github.com/hinihatetsu/root/go_pkg

となる。

配布する

これも試行錯誤した結果、最終的にソースからビルドするときはGoをインストールしてもらうことにした。 もちろん、ビルド済みのwheelも配布すれば実行環境にGoは必要ない。

試行錯誤の例

  • Dockerでビルドしてもらう → Dockerコンテナ内でも使えるsetup.pyが書けなくて没。
  • cソースから直接ビルドする → cgoはgccでビルドされるためWindows標準のmsvcではコンパイルできない。
  • MinGWやDockerを利用すればWindowsでもビルドできるが、その手間は。。。

setup.pyからビルドするためにbuildgo.pyというモジュールをルートに置いた。

要約すると、GoExtensionという拡張パッケージクラスのサブクラスを新しく作って、それのビルド方法をコマンドbuild_extに教えてやった。

# buildgo.py
from setuptools.extension import Extension
from setuptools.command.build_ext import build_ext as _build_ext
from setuptools import dep_util
from setuptools._distutils import spawn
from functools import reduce
from pathlib import Path
import os
import shutil

class GoExtension(Extension):
    name: str
    go_module_name: str
    pkgs: list[str]

    def __init__(self, 
        name: str, 
        go_module_name: str, 
        pkgs: list[str], 
        *args, **kwargs
    ) -> None:
        self.name = name
        self.go_module_name = go_module_name
        self.pkgs = pkgs
        super().__init__(name, [], *args, **kwargs)


class build_ext(_build_ext):
    def build_extension(self, ext: Extension) -> None:
        if isinstance(ext, GoExtension):
            install_build_tools()
            self.build_go(ext)
        else:
            super().build_extension(ext)


    def build_go(self, ext: GoExtension) -> None:
        assert isinstance(ext, GoExtension)
        ext_path = self.get_ext_fullpath(ext.name)
        get_src = lambda lst, pkg: lst + [file.as_posix() for file in Path(pkg).iterdir()]
        sources = reduce(get_src, ext.pkgs, [])
        dry_run = not (self.force or dep_util.newer_group(sources, ext_path, 'newer'))
        outdir = Path(ext_path).parent / ext.name.split(sep='.')[-1]
        os.environ['LD_LIBRATY_PATH'] = (os.environ.get('LD_LIBRATY_PATH', '') + ':' + os.curdir).strip(':')
        cmd = [
            'gopy',
            'build',
            '-name=go',
            '-no-make',
            '-rename',
            '-vm=python',
            f'-output={outdir}'
        ] + [ext.go_module_name+'/'+pkg for pkg in ext.pkgs]
        spawn.spawn(cmd, dry_run=int(dry_run))

class GoNotFoundError(Exception):
    pass


def install_build_tools():
    if not shutil.which('go'):
        raise GoNotFoundError('Go1.15 or above is required to build this package.')
    if not shutil.which('gopy'):
        print('installing gopy...')
        cmd = ['go', 'get', 'github.com/go-python/gopy']
        spawn.spawn(cmd)
    if not shutil.which('goimports'):
        print('installing goimports...')
        cmd = ['go', 'get', 'golang.org/x/tools/cmd/goimports']
        spawn.spawn(cmd)
    spawn.spawn(['python', '-m', 'pip', 'install', '-Uq', 'pybindgen'])

そしてsetup.pyを次のように書く。

from setuptools import setup
import sys
sys.path.append('.')
from buildgo import GoExtension, build_ext

go_pkg = GoExtension(
    name='py_pkg.go',
    go_module_name='github.com/hinihatetsu/root',
    pkgs=['go_pkg']
)

setup.py(
    ext_modules=[go_pkg],
    cmdclass={'build_ext': build_ext}
)

最後にMANIFEST.inをルートディレクトリに置いてGo関連のファイルをパッケージに含める。

include go_pkg/*
include go.mod
include go.sum

とりあえずこれでGithubからはpipでインストールできて、

pip install git+http://github.com/hinihatetsu/hogehoge

Goがあればビルドしてくれる。

ただ何故かPyPIからインストールしようとすると、Goパッケージだと認識されなくてビルドに失敗する。 今のところPyPIのwheelとGithubからのインストールがあれば良いかなということで原因究明はToDo送りとなった。#ToDo

もう一つ確認している問題はpython setup.py build_ext -iでインプレイスビルドができないので、インプレイスが必要な開発者用のMakefileを別途作っておく必要がある。#ToDo

Dockerでビルドする例

Dockerコンテナとして利用する際にマルチステージビルドする例を示す。

FROM golang:1.17-buster as go

FROM python:3.9-buster as builder

COPY --from=go /usr/local/go /go

WORKDIR /go
ENV GOROOT /go
ENV GOPATH /gohome
ENV PATH $PATH:$GOROOT/bin:$GOPATH/bin
RUN go get golang.org/x/tools/cmd/goimports \
 && go get github.com/go-python/gopy

RUN pip install -U pip \
 && pip install git+https://github.com/hinihat/remoteweb


FROM python:3.9-slim-buster as runner
COPY --from=builder /usr/local/lib/python3.9/site-packages /usr/local/lib/python3.9/site-packages

余談

このsetup.pyに組み込む部分も汎用性が高そうだからライブラリとして仕上げたいよね。作成した

最近ライブラリのリリース作業ばかりやっているが、README・Docstringの整備だとかテストコードの拡充だとかドキュメントの生成(Sphinx)・公開(GitPages)だとか拡張パッケージのビルドだとかCIへの登録だとかPyPIへのアップロードだとか、Pythonコード書くよりもしんどいことが多くて今週は毎日睡眠3時間とかで昨日爆睡して多少回復した。

これらの作業ってすべてのライブラリで必要なのに、ライブラリによって設定とか異なるから自動化できないのがキツイ。

GitとかDockerとかCIでかなり開発環境は良くなったんだろうけど、それでもコーディング、テスト、デプロイのフローを作るのは結構きつくて個人の限界に挑戦しているって感じ。

正直このようなStableなものにする作業はクソだと感じているので絶対にプログラマになることはないな。

テストコードは絶対に必要で、アップデートする際に既存の仕様を壊すことなく実装できるし、問題が発生しても基底のライブラリのテストがしっかりしてあれば表面のコードを調べるだけになってトラブルシューティングが楽になる。(というのを実体験した。)

まとめ

ということで数日間を生贄にGoソースからPythonパッケージを生成・配布する手段を習得した。

Goは通信関係の実装が得意であるため、今後も積極的に活用していきたいと考えている。

GoでPythonを拡張できるんだったらもうCythonいらなくね?

Ubuntuでpython3を消したら痛い目を見た

現在サブのコンピュータとしてUbuntuを使用しているが、基本的にプログラムはDockerで管理するからpython3要らんやろ、と適当にアンインストールしたら痛い目を見た。

実行したコマンド

$ sudo apt remove python3
$ sudo apt autoremove

Debian系なのでaptでパッケージ管理をしている。 そこからpython3を削除して、autoremoveでクリーンアップした。

発生した問題

再起動したらネットワークが壊れた。

インターネットはもちろん、LANからも接続できない。

トラブルシューティング

  1. 放電してから再起動 - 結果:×
  2. Ethernetポート、ケーブルの変更 - 結果:×
  3. ディスプレイに接続 - 結果: OSには問題なし。
  4. ログインして内側からping - 結果: ping通らず
  5. /etc/netplanディレクトリ・ファイルを確認 - 結果: 問題なし
  6. netplan apply - 結果: 「netplan コマンドが存在しません」

ということで、netplanpython3に依存していたせいでapt autoremoveで削除されてしまいましたとさ。

apt autoremove中にそんな感じのログが出ていたので、なんとなくそんな感じはしていた。

解決方法

全く同じアーキテクチャで全く同じOSのマシンをもう一台持っていたので、そのマシンでnetplanの依存関係を確認。

$ apt show netplan
Package: netplan
State: not a real package (virtual)
N: Can't select candidate version from package netplan as it has no candidate
N: Can't select versions from package 'netplan' as it is purely virtual
N: No packages found

どうやらnetplan自体はnetplan.ioからインストールされているらしい。

$ apt show netplan.io
Package: netplan.io
Version: 0.103-0ubuntu5~20.04.2
Priority: important
Section: net
Origin: Ubuntu
Maintainer: Ubuntu Developers <ubuntu-devel-discuss@lists.ubuntu.com>
Original-Maintainer: Debian netplan Maintainers <team+netplan@tracker.debian.org>
Bugs: https://bugs.launchpad.net/ubuntu/+filebug
Installed-Size: 487 kB
Provides: nplan
Depends: libc6 (>= 2.27), libglib2.0-0 (>= 2.39.4), libnetplan0 (>= 0.103-0ubuntu5~20.04.2), libsystemd0 (>= 243), libuuid1 (>= 2.16), libyaml-0-2, iproute2, python3, python3-yaml, python3-netifaces, systemd (>= 245.4-4ubuntu3.8)
Suggests: network-manager | wpasupplicant, openvswitch-switch
Conflicts: netplan
Breaks: network-manager (<< 1.2.2-1), nplan (<< 0.34~)
Replaces: nplan (<< 0.34~)
Homepage: https://netplan.io/
Task: minimal, ubuntu-core
Download-Size: 121 kB
APT-Manual-Installed: no
APT-Sources: http://ports.ubuntu.com/ubuntu-ports focal-updates/main arm64 Packages
Description: YAML network configuration abstraction for various backends
 netplan reads YAML network configuration files which are written
 by administrators, installers, cloud image instantiations, or other OS
 deployments. During early boot it then generates backend specific
 configuration files in /run to hand off control of devices to a particular
 networking daemon.
 .
 Currently supported backends are networkd and NetworkManager.

N: There is 1 additional record. Please use the '-a' switch to see it

問題のマシンで入っていないパッケージを調べて、それらの.debをダウンロードする。

$ mkdir netplan.io && cd netplan.io
$ sudo apt download netplan.io netplanlib0 python3-yaml python3-netifaces

USBフラッシュドライブで問題のマシンに移してインストール、再起動。

$ sudo apt install <downloaded>.deb
$ sudo reboot

pingが通って問題解決。

反省

python3を消してから、「そういえばLinuxではPythonをシステムが使ってるってどこかで見たな」と思い出してすぐに再インストールしたが、依存パッケージまで気が回っていなかった。

再起動しなければこの問題は発覚しなかったため、たまたま再起動して早期に問題が発覚したのは不幸中の幸いだった。 とはいえトラブルシューティング&問題解決に3時間要したため、今度からは慎重にパッケージをアンインストールしようと思う。

しかも今回は「以前Wi-Fi設定でnetplanを使用したことがある」から運良くトラブルシューティングすることができた。 これがもし使ったこともないUbuntuのデフォルトパッケージだったら、もっと時間がかかっただろう。

まあ反省はこの程度にして、中断していたDockerリポジトリの構築の続きを再開しよう。

(実はサーバーにUSBを接続するのも初めてだったので、そこでも手間取った。。。)

別の方法

noffさんがコメントで教えてくれたので紹介する。

自分も消してしまって、やらかしてしまったサーバー1台だけで復旧できたので共有させてもらいます。

  1. 削除されたパッケージを取得 /var/log/apt/history.log を開き、apt-get remove した時刻の行を探します。 その行に、Remove: <package name> (<version>), ... というフォーマットで削除されたパッケージの情報が書かれているため、これをコピーして別ファイルに保存しておきます。

  2. ネットワークの復旧 次のコマンドで、ネットワークインタフェースを設定します。

# ネットワークデバイス名の確認(enp3s0 とかそんな感じの名前)
sudo ip link show

# IPアドレスの静的割り当て
sudo ip addr add <ip address>/<cidr> dev <network device name>

# ネットワークデバイスの有効化
sudo ip link set <network device name> up

# デフォルトゲートウェイの設定
sudo ip route add default via <default gateway's ip address>

ここまで設定すれば、デフォルトゲートウェイへの ping に成功するはずですので確認します。 sh ping <default gateway's ip address>

  1. DNSの設定 次のコマンドで、dns_servers.conf を作成して設定を保存します。 sh sudo mkdir /etc/systemd/resolved.conf.d/ sudo vim /etc/systemd/resolved.conf.d/dns_servers.conf

dns_servers.conf [Resolve] DNS=8.8.8.8 1.1.1.1

保存したのちに、systemd-resolved サービスを再起動します。 sh sudo systemctl restart systemd-resolved

これで、ドメイン名を利用したインターネットアクセスが可能になっているはずですので、googleping を打って確認します。 sh ping www.google.com

  1. 削除されたパッケージの再インストール 手順 1. で保存したファイルに対し、バージョン番号とカンマを削除してから先頭へ sudo apt install と入力します。 vimコマンドラインモードで :s/([^)]\+),//g と入力するとバージョン名とカンマが消えます。 編集が終わったら chmod で実行権限を付与して実行します。 これで削除されたパッケージがすべてインストールできます。

  2. netplan の設定 ネットワーク情報が消えているため、必要に応じて /etc/netplan/00-installer-config.yaml を編集します。 DHCPにお任せな設定の場合は不要かもしれません。 sh cp /etc/netplan/00-installer-config.yaml /etc/netplan/00-installer-config.yaml.bak sudo vim /etc/netplan/00-installer-config.yaml 編集する場合は https://rohhie.net/ubuntu22-04-netplan-gateway4-has-been-deprecated/ あたりを参考にしてください。

  3. 再起動 復旧作業はこれで終了です。 再起動すると元通り!

参考: https://serverfault.com/questions/380856/how-to-undo-apt-get-remove https://askubuntu.com/questions/1208859/cannot-connect-to-the-internet-after-removing-python3-from-xubuntu https://serverok.in/systemd-resolved

PyPI公開へのメモ

PyPIへパッケージを公開するためのメモ。

PyPIへ登録

PyPIとテスト用のPyPIにアカウント登録する。

※本番用のPyPIに一度上げると同じバージョンでは再アップロードできない。

pypi.org

test.pypi.org

.pypirc をホームディレクトリに追加

.pypirc

[distutils]
index-servers =
  pypi
  testpypi

[pypi]
repository: https://upload.pypi.org/legacy/
username: username_here
password: password_here

[testpypi]
repository: https://test.pypi.org/legacy/
username: username_here
password: password_here

パッケージをビルド

必要なパッケージをインストール

pip install wheel twine

ビルド

python setup.py sdist
python setup.py bdist_wheel

ここでビルドしたwheelはもちろん、その環境に依存する。

アップロード

README.mdを対応させる

setup.cfg

[metadata]
...
long_description_content_type = text/markdown
...

テスト用にアップロード

twine upload -r testpypi dist/*

本番用にアップロード

twine upload -r pypi dist/*

Authentication System with FastAPI + SQLModel

FastAPIでログイン機能を15分で実装するためのTips。

Offitial Tutorial

fastapi.tiangolo.com

ログイン機能実装の勉強にはなるけど、わざわざ自分で書く必要もないよね。

pip install fastapi

SQLModel

sqlmodel.tiangolo.com

FastAPIの開発者が手掛けているSQLデータベースをPythonから操作するためのライブラリ。

SQLAlchemyをベースとしていて、それにPydanticを加えてより使いやすくしたもの。

SQLライブラリで迷っているならとりあえずチュートリアルをやってみるとよい。

Installation

pip install sqlmodel

FastAPI-Users

fastapi-users.github.io

I'll use FastAPI-Users though it's a smaller community than other major libraries such as FastAPI. This library sounds best for what I'm trying to do.

小さめのコミュニティだけど、FastAPI-Usersというライブラリを使わせてもらう。

SQLModelは一応サポートしているけどドキュメントはないので、ソースコード読みながら勘で実装した。 そんな人の時間を節約するためのメモ。

Installation

pip install fastapi-users
pip install fastapi-users-db-sqlmodel  # additional package for SQLModel

Concepts

FastAPI-Users`は次の4つの要素で構成することで、ログイン機能を単純化している。

  • モデル
    データベースに保存する情報をまとめたユーザモデル。
  • データベースアダプタ
    データベースとやり取りする。
    SQLAlchemy, MonagoDB, SQLModel, etc...
    今回はSQLModelで実装。
  • 認証バックエンド
    JWT or CookieCookie実装はうまく機能しなかったのでJWTで実装。
  • ユーザマネージャ
    データベース操作前後の処理のインターフェースを提供。
    今回はいじらない。

Code

インポート関係がわかりやすいように、敢えて分割してインポートしています。

login.py

## Define Models ##
# See also https://fastapi-users.github.io/fastapi-users/configuration/models/
from fastapi_users import models
from fastapi_users_db_sqlmodel import SQLModelBaseUserDB

class User(models.BaseUser):
    pass


class UserCreate(models.BaseUserCreate):
    pass


class UserUpdate(models.BaseUserUpdate):
    pass


class UserDB(SQLModelBaseUserDB, table=True):
    pass
    # See also https://sqlmodel.tiangolo.com/#create-a-sqlmodel-model



## Database Adapter ##
import sqlmodel
from fastapi_users_db_sqlmodel import SQLModelUserDatabase

# create engine. See also https://sqlmodel.tiangolo.com/#write-to-the-database
sqlengine = sqlmodel.create_engine('sqlite:///database.db', connect_args={"check_same_thread": False})

def get_user_db(): # used later
    with sqlmodel.Session(sqlengine) as session:
        yield SQLModelUserDatabase(UserDB, session)


## Authentication Backend(JWT)##
# See also https://fastapi-users.github.io/fastapi-users/configuration/authentication/jwt/
# About JWT, see https://fastapi.tiangolo.com/tutorial/security/oauth2-jwt/#jwt
from fastapi_users.authentication.jwt import JWTAuthentication

SECRET = 'SECRET_KEY_FOR_AUTH'
jwt_authentication = JWTAuthentication(secret=SECRET, lifetime_seconds=3600)


## User Manager ##
# See also https://fastapi-users.github.io/fastapi-users/configuration/user-manager/
from fastapi_users import BaseUserManager
from fastapi import Depends

class UserManager(BaseUserManager[UserCreate, UserDB]):
    user_db_model = UserDB
    reset_password_token_secret = SECRET
    verification_token_secret = SECRET


def get_user_manager(user_db=Depends(get_user_db)):
    yield UserManager(user_db)


## Add to FastAPI ##
from fastapi import FastAPI
from fastapi_users import FastAPIUsers

app = FastAPI() # create an app

fastapi_users = FastAPIUsers(  # Interface of FastAPI-Users
    get_user_manager,
    [jwt_authentication],
    User,
    UserCreate,
    UserUpdate,
    UserDB,
)

# Add basic routers for authentication
app.include_router(
    fastapi_users.get_auth_router(jwt_authentication),
    prefix="/auth",
    tags=["auth"],
)

app.include_router(
    fastapi_users.get_register_router(),
    prefix="/auth",
    tags=["auth"],
)

app.include_router(
    fastapi_users.get_verify_router(),
    prefix="/auth",
    tags=["auth"],
)

app.include_router(
    fastapi_users.get_reset_password_router(),
    prefix="/auth",
    tags=["auth"],
)

# Add routers returning user information.
app.include_router(
    fastapi_users.get_users_router(),
    prefix="/users",
    tags=["users"]
)

# Create models in database
sqlmodel.SQLModel.metadata.create_all(sqlengine) 

Run

pip install uvicorn
uvicorn login:app

Getting access to http://localhost:8000/docs, you can see web documents automatically created by FastAPI.

Usage

The following is a sample using requests library.

import requests

URL = 'http://localhost:8000'

# User Registeration
def register(email: str, password: str) -> requests.Response:
    data = {
        'email': email,
        'password': password
    }
    response = requests.post(f'{URL}/auth/register', json=data)
    return response


# Login
def login(email: str, password: str) -> str:
    data = {
        'email': email,
        'password': password
    }
    response = requests.post(f'{URL}/auth/login', data=data)
    token = response.json().get('access_token', '')
    return token


# Get User Information
def get_me(token: str) -> dict:
    headers = {'Authorization': f'Bearer {token}'}
    response = requests.get(f'{URL}/users/me', headers=headers}
    return response.json()

ToDo

  • OAuth2の実装
  • SQLエンジンをAsync化

結局追加機能つけてアプリケーションに組み込もうと思ったら公式ドキュメント見に行くしかなくてこの記事の意味はあるのかと。

SQLAlchemy使って実装するよりはSQLModelで実装した方がシンプルだから一応意味はあるのかな?

__init__()の引数を書き換えずに処理を追加する

Python Tips.

忘れないようにメモ。

実現したいこと

サードパーティ―ライブラリ(3rd-lib)の上に自身のライブラリを作りたい時、3rd-libのクラスを継承してサブクラスを作りたい時がある。

このとき、初期化処理を上書きしようと、

import pandas as pd

class MyDataFrame(pd.DataFrame):
    def __init__(self, *args, **kwargs) -> None:
        super().__init__(*args, **kwargs)
        do_something()

としてしまうと、スーパークラスの初期化引数の情報が失われてしまう。 この場合で言えば、data, columnsといったpd.DataFrameの初期化引数が失われてコード補完が効かなくなって効率が落ちる。

最も単純な実装はスーパークラスと同じ引数をサブクラスの__init__()にも書くことだが、3rd-libのスーパークラスが変更されるとサブクラスも変更しなければならないためスマートな方法ではない。

そこで引数の情報を残したまま、3rd-libの仕様変更に依存しない実装方法を提示する。

実装方法

__new__()を利用する。

import pandas as pd

class MyDataFrame(pd.DataFrame):
    def __new__(cls, *args, **kwargs) -> MyDataFrame:
        self = super(MyDataFrame, cls).__new__(cls)
        self.__init__(*args, **kwargs)
        do_something()
        return self

注意点としては、__new__()の後に__init__()が再び呼ばれるため、副作用のある処理を__init__()に書いているとバグが発生する可能性があることくらいか。

挙動テスト

test.py

class Super:
    def __init__(self):
        print('super __init__')


class Sub(Super):
    def __new__(cls, *args, **kwargs):
        self = super(Sub, cls).__new__(cls)
        self.__init__(*args, **kwargs)
        print('sub __new__')
        return self

s = Sub()

実行結果

$ python test.py
super __init__
sub __new__
super __init__

改良版

super __init__を一回だけ呼ぶ。

class Super:
    def __init__(self):
        print('super __init__')


class Sub(Super):
    def __new__(cls, *args, **kwargs):
        self = super(Sub, cls).__new__(cls)
        self.__init__(*args, **kwargs)
        print('sub __new__')
        def f(*args, **kwargs) -> None:
            pass
        cls.__init__ = f
        return self

s = Sub()

実行結果

$ python test.py
super __init__
sub __new__