c-bata web

tech blog by @c-bata

Github ActionsでクロスコンパイルしてGithub Releaseにアップロードする

この前kube-promptのリリースでミスをしてしまいissueが立て続けに2件上がったことがあったのですが、Github Actionsが自分のリポジトリで使えるようになったのでリリースを自動化することにしました。

触ってみてわかったのですが、↓の記事を書いたときとは多くの変更がありました。workflowの記述フォーマットがyamlに変更されていたり、Azure Pipelinesを裏側で使うようになったことでSupported virtual environmentsにmacOSWindowsが増えたり、その影響でCustom actionはDockerコンテナベースではなくJavaScriptアプリケーションとして記述できるようになったりしています。JavascriptでCustom actionを記述するのはまた今度にして、とりあえず今回はyaml形式のworkflowを使っていきます。

nwpct1.hatenablog.com

ロスコンパイルとGithub releasesへのアップロード

kube-promptではこんな感じでいくつかバイナリをGithub Releaseに含めています。今回は tagが作成されたタイミングでフックしてこれらのbinaryをcross compile, zipで圧縮してGithub Release画面を作成します。

f:id:nwpct1:20190820063438p:plain
kube-prompt github release画面

まずworkflowのevent trigger指定します。 今回からcronのように定期実行もできるようになっていますが、基本的には GithubのWebhook event を起動トリガーにすることがほとんどかと思います。git tagのcreateイベントをトリガーにしたいので次のようになります 1

on:
  create:
    tags:
    - v*.*.*

次にjobs propertyより実際にGithub Actionsで行いたい操作を記述します。 そんなに長くなくて↓のような感じです。

jobs:
  release:  # job_id
    name: Build
    runs-on: ubuntu-latest
    steps:

    - name: Set up Go 1.12
      uses: actions/setup-go@v1
      with:
        version: 1.12
      id: go

    - name: Check out code into the Go module directory
      uses: actions/checkout@master

    - name: Build
      env:
        GO111MODULE: on
        GOPATH: /home/runner/work/
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      run: |
        export CREATE_EVENT_REF_TYPE=$(jq --raw-output .ref_type "$GITHUB_EVENT_PATH")
        export TAGNAME=$(jq --raw-output .ref "$GITHUB_EVENT_PATH")
        if [ "$CREATE_EVENT_REF_TYPE" != "tag" ]; then echo "ref_type is not a tag: ${CREATE_EVENT_REF_TYPE}" && exit 78; fi
        make cross
        go get -u github.com/tcnksm/ghr
        $GOPATH/bin/ghr -n ${TAGNAME} -b "ʕ◔ϖ◔ʔ Release ${TAGNAME}" -draft ${TAGNAME} pkg/
  • jobs.<job_id>.runs-on に実行環境を指定することができて、今回からmacOSWindowsが追加されました。kube-promptはCGOの依存とかもなく普通にクロスコンパイルできるので、一番速度や動作が安定してそうなubuntuにしています。他のCIサービスと同様に、matrixも組めるのでテストとか必要に応じてやっておくといいかと思います。
  • GITHUB_TOKENは以前までだと自分でsecretsに登録する必要がありましたが、今回からはその必要も無いようです。yaml内で ${{secrets.GITHUB_TOKEN}} とするだけで取り出せます。
    • どこまでが古いドキュメントにしか書かれてなくて、どこまでが新しいドキュメントに書かれてるのか分からずこれに気づくの少し時間がかかってしまいました。
  • $GITHUB_EVENT_PATH にはWebhook eventのJSONファイルがそのまま入っています。今回は create イベントにフックしているので、 こちら にあるように、 ref_type フィールドからtag名、 ref フィールドからrevision hashが取り出せます。
  • make cross の処理はこんな感じです https://github.com/c-bata/kube-prompt/blob/4e31373f5a10443746b4ab93d584ea0ec17d4e61/Makefile#L31-L43
  • 最後にghrをgo getしてきてアップロードします。Githubのtokenは GITHUB_TOKEN 環境変数を見てくれているので特にコマンド実行時には指定する必要がありません。
  • ~ちなみに actions/setup-go$GOPATH が空で $GOPATH/bin にもパスが通っていないようなので、明示的に指定しました。直したほうがいい気がしますが、JavaScriptベースのCustom actionとして定義されているらしく、まだ自分も調べてないところだったので今度必要になって調べることがあればついでに提案してみたいとおもいます。~
    • uses で何もDockerイメージが指定されていないstepは、 runs-on で指定したホスト上でそのまま動いているかもです。 setup-go はそのホスト上にGoの環境を構築してくれているということな気がします。ただ環境変数とかは引き継げないのかな?
    • https://github.com/actions/setup-go

全体像はこんな感じです。試しに v0.0.1 みたいなタグをpushして無事にバイナリ付きでGithub Releaseが作成されることは確認しました。

https://github.com/c-bata/kube-prompt/blob/master/.github/workflows/release.yml

おまけ: golangci-lintとgo testの実行

Goptunaという最近開発してるプロジェクトはまだCIを導入していなかったので、Lintとテストを実行できるようにしました。 他のプロジェクトでもほとんどそのまま使える点も多いと思うのでよければ参考にしてみてください。

name: Run tests and lint checks
on:
  pull_request:
    branches:
    - master
jobs:
  lint:
    name: Lint checking on Ubuntu
    runs-on: ubuntu-latest

    steps:
    - name: Set up Go 1.12
      uses: actions/setup-go@v1
      with:
        version: 1.12
      id: go

    - name: Check out code into the Go module directory
      uses: actions/checkout@master

    - name: Running golangci-lint
      env:
        GO111MODULE: on
        GOPATH: /home/runner/work/
      run: |
        go get -u github.com/golangci/golangci-lint/cmd/golangci-lint
        GOCILINT=${GOPATH}/bin/golangci-lint make lint
  test:
    name: Test on ${{ matrix.os }}
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest, macOS-latest]

    steps:
    - name: Set up Go 1.12
      uses: actions/setup-go@v1
      with:
        version: 1.12
      id: go

    - name: Check out code into the Go module directory
      uses: actions/checkout@master

    - name: Running go tests
      run: make test

https://github.com/c-bata/goptuna/blob/master/.github/workflows/run-tests.yml

追記:

Go関係ないですが、ついでにSphinxのドキュメントをビルドしてGCSにアップロードするscript書いたのでgcloudとかgsutil使いたい方とか参考にしたい方はどうぞ。本当は https://github.com/actions/gcloud を使おうと思ったのですが柔軟性なくて今回の用途には使えなかったのでやめました。

Github Actions Workflow to build your sphinx documentation and upload it to Google Cloud Storage. https://gist.github.com/c-bata/ed5e7b7f8015502ee5092a3e77937c99

おわりに

travis CIを長く使ってきましたが、Windowsの動作が不安定だったり苦労している点が結構ありました。 ActionsはAzure Pipelinesがバックエンドにあることもあり、立ち上がりがかなり速い印象です。 forkしたらそのままActionsが使えるというメリットもあるので、OSSでは積極的に使っていこうと思います。


  1. ちなみに複数のイベントにトリガーさせることもできて、 この例 がわかりやすかったです。

GoptunaのRDB storage backendを使った分散ハイパーパラメーター最適化

先月は会社の技術ブログ執筆当番となっていたため、そのネタづくりに Goptuna を実装・公開しました。記事公開時にはTPEとUniformDistributionしかありませんでしたが、その後もコツコツ開発を続け現在はCategoricalDistributionなどOptunaがサポートしているDistributionを全て実装し、枝刈りなどの機能にも一部対応しています。

adtech.cyberagent.io

今の機能なら業務にも投入できるかなと思っていたのですが、いざ使おうとすると最適化の結果を永続化できないこと・Dashboardが見れないことは結構問題でした。今回はGormを使ってがっと書いてみた ので使い方のメモも兼ねて紹介します。 OptunaのDB定義と互換性があるので、Goptunaの実行結果をOptunaのdashboardで閲覧できます。

SQLite3で動かす

Studyの作成

RDB storage backendを追加するついでにOptunaのようなCLIを定義したので、こちらでまずはstudyを作成します。

$ ./bin/goptuna 
A command line interface for Goptuna

Usage:
  goptuna [command]

Available Commands:
  create-study Create a study in your relational database storage.
  help         Help about any command

Flags:
  -h, --help      help for goptuna
      --version   version for goptuna

Use "goptuna [command] --help" for more information about a command.

$ goptuna create-study --storage sqlite:///db.sqlite3 --study rdb
rdb

optuna create-study と基本的に同じ使い方なので、Optunaに慣れた方であれば同じように使えるかと思います。 例えば --storage オプションとかはSQLAlchemyのEngine Database URL formatがそのまま使えます ( https://docs.sqlalchemy.org/en/13/core/engines.html を裏側でパースしてGoのData Source Name形式に変換しています)。 --study オプションはOptunaと同じく省略するとuuidv4を使って自動で名前を割り当てます。

実行後のdb.sqlite3の中はこんな感じです。

$ sqlite3 db.sqlite3 
SQLite version 3.28.0 2019-04-16 19:49:53
Enter ".help" for usage hints.
sqlite> .header on
sqlite> .mode column
sqlite> .tables
alembic_version          trial_params             trials                 
studies                  trial_system_attributes  version_info           
study_system_attributes  trial_user_attributes  
study_user_attributes    trial_values           
sqlite> select * from studies;
study_id    study_name  direction 
----------  ----------  ----------
1           rdb         MINIMIZE  

ちなみにGORMにはauto migrationの機能がありますが、SQLAlchemy + Alembicのようにマイグレーションファイルの差分情報からうまくSQL生成するような機能はありません。Goptuna CLIでテーブルを作成したときは基本的にあとから新しい定義にマイグレーションできないものだと思ってもらったほうが良いかと思います (ハイパーパラメータ最適化では実用上マイグレーションが問題になることはあまりないとは思いますが)。もし長期的にデータベースを管理していきたい場合は、ここだけOptuna CLIを使ってstudyを作成することも可能なのでそちらを検討してください。

StudyをLoadして最適化

最適化は次のように行います。 StudyはCLIで作成済みのため、goptuna.LoadStudy を使用します。あとはrdb.NewStorage(db)*gorm.DB を渡してそれをLoadStudyに指定します。

package main

import (
    "flag"
    "fmt"
    "math"
    "os"

    "github.com/c-bata/goptuna"
    "github.com/c-bata/goptuna/rdb"
    "github.com/c-bata/goptuna/tpe"
    "github.com/jinzhu/gorm"
    "go.uber.org/zap"

    _ "github.com/jinzhu/gorm/dialects/sqlite"
)

func objective(trial goptuna.Trial) (float64, error) {
    x1, _ := trial.SuggestUniform("x1", -10, 10)
    x2, _ := trial.SuggestUniform("x2", -10, 10)
    return math.Pow(x1-2, 2) + math.Pow(x2+5, 2), nil
}

func main() {
    logger, err := zap.NewDevelopment()
    if err != nil {
        os.Exit(1)
    }
    defer logger.Sync()

    db, err := gorm.Open("sqlite3", "db.sqlite3")
    if err != nil {
        logger.Fatal("failed to open db", zap.Error(err))
    }
    storage := rdb.NewStorage(db)
    defer db.Close()

    study, err := goptuna.LoadStudy(
        "rdb",
        goptuna.StudyOptionStorage(storage),
        goptuna.StudyOptionSampler(tpe.NewSampler()),
        goptuna.StudyOptionSetDirection(goptuna.StudyDirectionMinimize),
        goptuna.StudyOptionSetLogger(logger),
    )
    if err != nil {
        logger.Fatal("failed to create study", zap.Error(err))
    }

    if err = study.Optimize(objective, 50); err != nil {
        logger.Fatal("failed to optimize", zap.Error(err))
    }

    v, err := study.GetBestValue()
    if err != nil {
        logger.Fatal("failed to get best value", zap.Error(err))
    }
    params, err := study.GetBestParams()
    if err != nil {
        logger.Fatal("failed to get best params", zap.Error(err))
    }
    fmt.Println("Result:")
    fmt.Println("- best value", v)
    fmt.Println("- best param", params)
}

実行すると次のようになります。

$ go run main.go
2019-08-13T18:41:16.852+0900    INFO    goptuna/study.go:116    Finished trial  {"trialID": 1, "state": "Complete", "value": 47.67429274143393, "params": "map[x1:8.903922985882328 x2:-5.100698294124405]"}
2019-08-13T18:41:16.860+0900    INFO    goptuna/study.go:116    Finished trial  {"trialID": 2, "state": "Complete", "value": 16.564974686882508, "params": "map[x1:3.1191253039081026 x2:-8.913123208005992]"}
...
2019-08-13T18:41:17.281+0900    INFO    goptuna/study.go:116    Finished trial  {"trialID": 50, "state": "Complete", "value": 27.09517768964176, "params": "map[x1:-3.1410659891485793 x2:-5.81524118202008]"}
Result:
- best value 0.03832650787671685
- best param map[x1:2.1816038398071225 x2:-4.926879871143263]

Optuna Dashboardで確認

DBスキーマはOptunaと互換性があるので、Dashboardで結果を見てみましょう。

$ python3.7 -m venv venv
$ source venv/bin/activate
$ pip install optuna bokeh
$ optuna dashboard --storage sqlite:///db.sqlite3 --study rdb
[W 2019-08-13 18:41:18,756] Optuna dashboard is still highly experimental. Please use with caution!
[I 2019-08-13 18:41:18,764] Starting Bokeh server version 1.3.4 (running on Tornado 6.0.3)
[I 2019-08-13 18:41:18,768] Bokeh app running at: http://localhost:5006/dashboard
[I 2019-08-13 18:41:18,768] Starting Bokeh server with process id: 47887

f:id:nwpct1:20190813185112p:plain
optuna-dashboard

問題なく確認できました。

MySQLで動かす

GILの制約をうけてしまうOptunaとは違いRDB storage backendを使用しなくてもGoroutineですでに複数のCPUコアを効率よく使うことはできたので、SQLite3が使えるだけでは結果がファイルとして保存されていてあとから再開したりdashboardを見たりできる以外にはメリットがありません。複数台のマシンを使った分散ハイパーパラメータ最適化のためには、tcpで通信ができるMySQLなどを使うことになるかと思います。動作確認も兼ねてMySQLで動かしてみます。

DockerでMySQL 8.0を用意

$ cat mysql/my.cnf 
[mysqld]
bind-address = 0.0.0.0
default_authentication_plugin=mysql_native_password

$ docker pull mysql:8.0
$ docker run \
  -d \
  --rm \
  -p 3306:3306 \
  --mount type=volume,src=mysql,dst=/etc/mysql/conf.d \
  -e MYSQL_USER=goptuna \
  -e MYSQL_DATABASE=goptuna \
  -e MYSQL_PASSWORD=password \
  -e MYSQL_ALLOW_EMPTY_PASSWORD=yes \
  --name goptuna-mysql \
  mysql:8.0

Goptuna CLIを使ってstudyを作成

storageにはSQLAlchemyのDatabase Engine URL formatがこちらも使用できます (goptuna側でパースしてGoのData Source Nameに変換しています)。現状はまだSQLite3とMySQLしかdialectをサポートしていないので、PostgreSQLとか使いたい場合はIssueかPRをお願いします。

$ goptuna create-study --storage mysql://goptuna:password@localhost:3306/goptuna
no-name-d704a908-bda1-11e9-8f4c-acde48001122

データを確認

$ mysql --host 127.0.0.1 --port 3306 --user goptuna -ppassword
mysql> show tables from goptuna;
+-------------------------+
| Tables_in_goptuna       |
+-------------------------+
| studies                 |
| study_system_attributes |
| study_user_attributes   |
| trial_params            |
| trial_system_attributes |
| trial_user_attributes   |
| trial_values            |
| trials                  |
+-------------------------+
8 rows in set (0.00 sec)

mysql> select * from studies;
+----------+----------------------------------------------+-----------+
| study_id | study_name                                   | direction |
+----------+----------------------------------------------+-----------+
|        1 | no-name-d704a908-bda1-11e9-8f4c-acde48001122 | MINIMIZE  |
+----------+----------------------------------------------+-----------+
1 row in set (0.00 sec)

実行

コードは先程とほとんど変わらず、dialectが mysql になり、DSNをそれにあわせて変更するだけです。 DSNは、 parseTime=true を忘れずにつけてください。

package main

import (
    "flag"
    "fmt"
    "math"
    "os"

    "github.com/c-bata/goptuna"
    "github.com/c-bata/goptuna/rdb"
    "github.com/c-bata/goptuna/tpe"
    "github.com/jinzhu/gorm"
    "go.uber.org/zap"

    _ "github.com/jinzhu/gorm/dialects/mysql"
    _ "github.com/jinzhu/gorm/dialects/sqlite"
)

func objective(trial goptuna.Trial) (float64, error) {
    x1, err := trial.SuggestUniform("x1", -10, 10)
    if err != nil {
        return 0.0, err
    }
    x2, err := trial.SuggestUniform("x2", -10, 10)
    if err != nil {
        return 0.0, err
    }
    return math.Pow(x1-2, 2) + math.Pow(x2+5, 2), nil
}

func main() {
    logger, err := zap.NewDevelopment()
    if err != nil {
        os.Exit(1)
    }
    defer logger.Sync()

    db, err := gorm.Open("mysql", "goptuna:password@tcp(localhost:3306)/goptuna?parseTime=true")
    if err != nil {
        logger.Fatal("failed to open db", zap.Error(err))
    }

    storage := rdb.NewStorage(db)
    defer db.Close()

    study, err := goptuna.LoadStudy(
        "rdb",
        goptuna.StudyOptionStorage(storage),
        goptuna.StudyOptionSampler(tpe.NewSampler()),
        goptuna.StudyOptionSetDirection(goptuna.StudyDirectionMinimize),
        goptuna.StudyOptionSetLogger(logger),
    )
    if err != nil {
        logger.Fatal("failed to create study", zap.Error(err))
    }

    if err = study.Optimize(objective, 50); err != nil {
        logger.Fatal("failed to optimize", zap.Error(err))
    }

    v, err := study.GetBestValue()
    if err != nil {
        logger.Fatal("failed to get best value", zap.Error(err))
    }
    params, err := study.GetBestParams()
    if err != nil {
        logger.Fatal("failed to get best params", zap.Error(err))
    }
    fmt.Println("Result:")
    fmt.Println("- best value", v)
    fmt.Println("- best param", params)
}

Dashboardで確認

Dashboardで確認するときは次のコマンド

$ optuna dashboard --storage mysql+mysqldb://goptuna:password@127.0.0.1:3306/goptuna --study rdb

まとめ

とりあえず動かし方をメモもかねてMySQLやSQLite3を使う方法を紹介しました。GORMが思っていたより使いやすく結構いいペースで実装できました。Dashboardも使えるようになったのは実用上かなり大きいかなと思っています。

Optunaの開発チームの方でDashboardを1から作っているようなので場合によっては静的ファイルをGoのバイナリにbundleして、Goptuna CLIからdashboard立てられるようにしたい。

Djangoライブラリのテストを setup.py testで実行するためのTestCommand

Djangoのアプリケーションをライブラリとして公開する際の、ファイル構成はいろいろな選択肢があります。 通常のプロジェクトの始め方と同じように django-admin startproject でプロジェクトを作成しその中に作成したアプリケーションを公開する場合や、プロジェクトは用意せずに django-admin startapp your-awesome-library でアプリケーションだけを作成する場合などがあります。

基本的には前者のやり方が悩みどころが少なく簡単ですが、パッケージ構成は後者のほうがスッキリするかもしれません。このあたりは正解がなくOSSのプロジェクトによっても様々なので、好きな方を選ぶ必要があるかと思います。最近 django-httpbench というライブラリを公開したのですが、そこでは後者を選びました。

ただテストの実行方法に関して悩みどころがあります。 DJANGO_SETTINGS_MODULE=test_settings python3 -m django test のように実行していましたが、Pythonパッケージとしては python3 setup.py test で統一的にテストを実行できるほうが嬉しいでしょう。jazzbandにあるリポジトリを眺めているとその多くが setup.py test には対応していませんでいたが、対応しているものの中にはテストファイル内で django.setup などを呼び出しているものがありました。あまりそういうやり方も取りたくないので、Pytest のやり方を参考にDjango用のクラスを用意しました。

settings.py は --settings オプションで manage.py コマンドと同じように指定できるようにしました。 test_suites に指定されたパッケージを TestRunnerのtest_labelsに渡して実行しています。

import os
import sys

from setuptools import setup, find_packages
from setuptools.command.test import test as TestCommand

class DjangoTestCommand(TestCommand):
    user_options = TestCommand.user_options + [
        ('settings=', None, "The Python path to a settings module"),
    ]

    def initialize_options(self):
        TestCommand.initialize_options(self)
        self.settings = ''

    def run_tests(self):
        # import here, cause outside the eggs aren't loaded
        import django
        from django.conf import settings
        from django.test.utils import get_runner

        if self.settings:
            os.environ['DJANGO_SETTINGS_MODULE'] = self.settings
        django.setup()
        TestRunner = get_runner(settings, test_runner_class=self.test_runner)
        test_runner = TestRunner()
        test_labels = [self.test_suite]
        failures = test_runner.run_tests(test_labels)
        if failures:
            sys.exit(1)


setup(
    name='djangohttpbench',
    ...,
    install_requires=[
        'Django',
        'requests',
    ],
    test_suite='httpbench.tests',
    tests_require=[],
    cmdclass={'test': DjangoTestCommand}
)

全体: django-httpbench/setup.py at master · c-bata/django-httpbench · GitHub

実行する際はこんな感じです。

$ python setup.py test --settings=test_settings
...
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
....
----------------------------------------------------------------------
Ran 4 tests in 0.002s

OK
Destroying test database for alias 'default'...

または DJANGO_SETTINGS_MODULE=test_settings python setup.py test ともできます。 また setup.py test の --test-runner オプションとかはそのまま付けることができるので次のように明示的に指定することも可能です。

$ python setup.py test --settings=test_settings --test-runner=django.test.runner.DiscoverRunner
...
Creating test database for alias 'default'...
System check identified no issues (0 silenced).
....
----------------------------------------------------------------------
Ran 4 tests in 0.002s

OK
Destroying test database for alias 'default'...

メモ: djangojaでのやりとり

hirokiky [9:00 AM]
テスト用のSettings指定するかなぁ

tokibito [12:09 PM]
pytest-djangoでテスト用プロジェクトのsettings指定に落ち着いたよ
テスト実行はCIと、手元ではtox使っているから、setup.pyにこだわらなくなった
https://github.com/tokibito/django-ftpserver
GitHub
tokibito/django-ftpserver
FTP server application that used user authentication of Django. - tokibito/django-ftpserver
こういう感じ

tokibito [1:13 PM]
Django向けのライブラリを作るとき、アプリやプロジェクトがあることを前提とすること多いし、独自の仕組にしてDjangoバージョン上がったときに動かなくなること結構あった

c-bata [1:51 PM]
kyさんもsetup.pyにはこだわらずに実行してる感じですかね
> テスト実行はCIと、手元ではtox使っているから、setup.pyにこだわらなくなった

なるほど

hirokiky [1:59 PM]
ですねー。toxで書いちゃう。
でも手元で1環境でテスト実行したいときに、 DJANGO_SETTINGS… って書いてだりーなぁって思う
--pdb つけたいときとか。

c-bata [2:10 PM]
なるほどー
setup.py test対応するのって統一的なインターフェイスがあるといいよねって話かと思ってますが、たしかにtoxとか使ってるプロジェクトなら `tox` とだけ打てばテスト実行できますしね

hirokiky [2:15 PM]
そうねぇ。あんまりsetup.py test使わないのも何か悲しいけどね。

tokibito [2:38 PM]
testコマンドでtox叩こうぜ

toxでやるならこんな感じでもいいかもですね

import os
import subprocess
import sys

from setuptools import setup, find_packages
from setuptools.command.test import test as TestCommand

class ToxTestCommand(TestCommand):
    user_options = [
        ('env=', 'e', "work against specified environments (ALL selects all)"),
    ]

    def initialize_options(self):
        TestCommand.initialize_options(self)
        self.env = None

    def run_tests(self):
        popenargs = ['tox']
        if self.env:
            popenargs += ['-e', self.env]
        sys.exit(subprocess.call(popenargs))


setup(
    ...
    test_suite='',
    tests_require=['tox'],
    cmdclass={'test': ToxTestCommand}
)

tox使う以上 test_suite は使われないのですが、とりあえず動きます

$ python setup.py test -e py37
....
----------------------------------------------------------------------
Ran 4 tests in 0.004s

OK
Destroying test database for alias 'default'...
___________________________________________________________________________________________ summary ____________________________________________________________________________________________
  py37: commands succeeded
  congratulations :)