GitLab CI + docker-reviewを使って、Markdownをtextlintしてからpdf化するCI環境を作ってみた

最近、Markdownからpdfを作る機会がありました。

pdf化を都度行うのは手間だったため、何か良い方法がないかを探したところ、 Markdown > Re:VIEW > pdf という経路でpdfを作成できそうでした。

ただ、手動でpdfを生成するのが手間だったため、GitLab CIを使って、push後にMarkdownをtextlintしてからpdf化するCI環境を作ってみました。

 

目次

 

環境

項目 内容
CI環境 GitLab.comのCI
Lintツール textlint
Lint辞書 自作
Markdownからpdf化の方法 Markdown > Re:VIEW > pdf
MarkdownからRe:VIEWへの変換 md2review
Re:VIEWからpdfの変換 review-pdfmaker
pdfのレイアウト調整 Re:VIEWなどに詳しくないため、今回は行わない

 
また、Gitリポジトリディレクトリ構成は以下の通りです。

pdf化する対象のMarkdowntarget.md です。それ以外のファイルについては後述します。

$ tree
.
└── docs
    ├── catalog.yml
    ├── config.yml
    ├── prh.yml
    ├── sty
    │   ├── README.md
    │   ├── gentombow.sty
    │   ├── jsbook.cls
    │   ├── jumoline.sty
    │   ├── plistings.sty
    │   ├── review-base.sty
    │   ├── review-custom.sty
    │   ├── review-jsbook.cls
    │   ├── review-style.sty
    │   ├── reviewmacro.sty
    │   └── techbooster-doujin-base.sty
    ├── style-web.css
    ├── style-web.scss
    ├── style.css
    ├── style.scss
    └── target.md

 
また、対象のMarkdownファイル target.md は以下のような感じです。

(Pythonコードについては、実際はトリプルバッククォートで囲みましたが、はてなブログに貼り付ける都合上、トリプルシングルクォートにしてあります)

# タイトル

Pythonコードを書きます。

'''
print('Hello world')
'''


## りんごの時期

以下の表を参照のこと。

|項目|時期|
|---|---|
|夏あかり|お盆過ぎ|
|シナノドルチェ|9月中頃|
|シナノゴールド|10月過ぎ|

 

GitLab CIの設定ファイル .gitlab-ci.yml の作成

GitLabでCIを動かすために .gitlab-ci.yml が必要なので作成します。

 
今回は、以下のようなPipelineを作成します。

No 対象ブランチ 条件 ジョブの内容
1 全ブランチ GitLabへpush時 対象のMarkdownにtextlintを実行
2 指定ブランチ No.1成功時 Markdownをpdf化

 

ステージを定義

ジョブを順番に実行するため、ステージを2つ定義します (lintbuild)。

stages:
  - lint
  - build

 

ジョブを定義

2つのジョブ markdown_lintpdf_build を定義します。

また、ジョブの stage にて、各ジョブがどのステージで動作するかを指定します。

markdown_lint:
  stage: lint

pdf_build:
  stage: build

 

ジョブ「対象のMarkdownにtextlintを実行」の詳細を定義

ジョブ markdown_lint は、対象のMarkdownにtextlintを実行します。

以下を参考に、今回は textlinttextlint-rule-prh を使うことにします。
textlint + prhで表記ゆれを検出する | Web Scratch

 
textlintはNode.jsがあれば動作するようなので、Dockerの公式リポジトリから一番軽そうなイメージ node:11.12.0-alpine を使います。

before_script でtextlintのインストールを行い、 script でtextlintを実行します。

ジョブの全体は以下の通りです。

markdown_lint:
  image: node:11.12.0-alpine
  stage: lint
  before_script:
    - npm install -g textlint textlint-rule-prh
  script:
    - cd docs
    - textlint target.md

 

ジョブ「Markdownをpdf化する」の詳細を定義

ジョブ pdf_build は、Markdownファイルをpdf化します。

今回、Markdown > Re:VIEW > pdfの変換では、以下のツールを使います。

 
Dockerイメージは、必要なファイルがすべて含まれている vvakame/review を使います。ありがとうございます。
https://github.com/vvakame/docker-review

 

あとは、 before_script にて必要なものをインストールし、 script にて変換を行います。

pdf_build:
  image: vvakame/review
  stage: build
  before_script:
    - apt-get update -y
    - apt-get -y install build-essential ruby-dev
    - gem install md2review
  script:
    - cd docs
    - md2review target.md > target.re
    - review-pdfmaker config.yml

 
なお、変換後のpdfをダウンロードできるようにするため、 artifacts を定義します。

今回は、中間でできる target.re ファイルやpdfなど、Git管理外ファイルをダウンロードするため、 untracked: true とします。

artifacts:
  untracked: true

 
また、pdfをビルドするブランチを masterfeature/* に制限するため、 only も定義しておきます。

  only:
    - master
    - /^feature\/.*$/

 
設定ファイル .gitlab-ci.yml の全体は以下の通りです。

stages:
  - lint
  - build

markdown_lint:
  image: node:11.12.0-alpine
  stage: lint
  before_script:
    - npm install -g textlint textlint-rule-prh
  script:
    - cd docs
    - textlint target.md

pdf_build:
  image: vvakame/review
  stage: build
  before_script:
    - apt-get update -y
    - apt-get -y install build-essential ruby-dev
    - gem install md2review
  script:
    - cd docs
    - md2review target.md > target.re
    - review-pdfmaker config.yml
  artifacts:
    untracked: true
  only:
    - master
    - /^feature\/.*$/

 

textlint向けの定義

以下を参考に、textlint向けの定義を行います。
textlint + prhで表記ゆれを検出する | Web Scratch

今回は、 doc ディレクトリの中に、 prh.ymltextlintrc を作成します。

 

prh.yml

まずは prh.yml に、textlintでチェックする内容を記載します。

今回は「Python」という単語の表記ゆれはエラーとなるように設定します。

version: 1
rules:
  - expected: Python
    specs:
      - from: python
        to:   Python
      - from: PYTHON
        to:   Python

 

.textlintrc

textlint実行時に prh.yml を参照するよう設定します。

{
  "rules": {
    "prh": {
      "rulePaths": [
        "./prh.yml"
      ]
    }
  }
}

 

review-pdfmaker向けの設定

review-pdfmaker向けの設定については、以下のリポジトリを参考にしました。また、一部ファイルはそのまま利用させていただきました。ありがとうございました。
https://github.com/TechBooster/ReVIEW-Template

 

config.ymlの設定

review-pdfmaker を実行する時の定義ファイル config.ymldoc ディレクトリの中に作成します。

ReVIEW-Templateを元に、以下の内容を記載します。

# この設定ファイルでサポートするRe:VIEWのバージョン番号。
# major versionが違うときにはエラーを出す。
review_version: 3.0

# ブック名(ファイル名になるもの。ASCII範囲の文字を使用)
bookname: my_sample_book
# 記述言語。省略した場合はja
language: ja

# 書名
booktitle: {name: "サンプル本", file-as: "sample book"}

# 著者名。「, 」で区切って複数指定できる
aut: [{name: "thinkAmi", file-as: "thinkAmi"}]

# 以下はオプション
# a-edt, edt: 編集者
edt: ["my editor"]
# a-pbl, pbl: 出版社(発行所)
pbl: thinkami_pub.

# 刊行日(省略した場合は実行時の日付)
date: 2019-3-24
# 発行年月。YYYY-MM-DD形式による配列指定。省略した場合はdateを使用する
# 複数指定する場合は次のように記述する
# [["初版第1刷の日付", "初版第2刷の日付"], ["第2版第1刷の日付"]]
# 日付の後ろを空白文字で区切り、任意の文字列を置くことも可能。
history: [["2019-3-24 v0.0.1"]]
# 権利表記(配列で複数指定可)
# rights: (C) 2016 Re:VIEW Developers
rights: (C) 2019 thinkAmi

# デバッグフラグ。nullでないときには一時ファイルをカレントディレクトリに作成し、削除もしない
debug: null

# HTMLファイルの拡張子(省略した場合はhtml)
htmlext: html
#
# CSSファイル(配列で複数指定可、yamlファイルおよびRe:VIEWファイルを置いたディレクトリにあること)
stylesheet: ["style.css"]

# ePUBのバージョン (2か3)
epubversion: 3
#
# HTMLのバージョン (4か5。epubversionを3にしたときには5にする)
htmlversion: 5

# 目次として抽出する見出しレベル
toclevel: 3

# 採番の設定。採番させたくない見出しには「==[nonum]」のようにnonum指定をする
#
# 本文でセクション番号を表示する見出しレベル
secnolevel: 3

# 本文中に目次ページを作成するか。省略した場合はnull (作成しない)
toc: true

# 表紙の後に大扉ページを作成するか。省略した場合はnull (作成しない)
titlepage: true

# 奥付を作成するか。デフォルトでは作成されない。trueを指定するとデフォルトの奥付、ファイル名を指定するとそれがcolophon.htmlとしてコピーされる
colophon: true

# EPUBにおけるページ送りの送り方向、page-progression-directionの値("ltr"|"rtl"|"default")
direction: "ltr"

epubmaker:
  # HTMLファイルの拡張子
  htmlext: xhtml
  stylesheet: ["style.css","epub_style.css"]

# LaTeX用のスタイルファイル(styディレクトリ以下に置くこと)
texstyle: ["reviewmacro"]

# B5の設定(10pt 40文字×35行) - 紙版
texdocumentclass: ["review-jsbook", "media=print,paper=b5,serial_pagination=true,hiddenfolio=nikko-pc,openany,fontsize=10pt,baselineskip=15.4pt,line_length=40zw,number_of_lines=35,head_space=30mm,headsep=10mm,headheight=5mm,footskip=10mm"]

pdfmaker:
  # 奥付を作成するか。trueを指定するとデフォルトの奥付、ファイル名を指定するとそれがcolophon.htmlとしてコピーされる
  colophon: true

webmaker:
    stylesheet: ["style.css","style-web.css"]

 

pdf化するコンテンツの設定 (catalog.yml)

pdfに含まれるコンテンツの設定を行います。

本文部分のRe:VIEWファイルは、 target.md が変換された target.re になります。そのため、 target.reCHAPS に指定します。

PREDEF:

CHAPS:
  - target.re

APPENDIX:

POSTDEF:

 

デザインまわりのファイルのコピー

今回デザインまわりは何も修正しないため、リポジトリ TechBooster/ReVIEW-Template のものをそのままコピーしました。

コピーするのは以下です。

 

以上で準備が終わりました。

 

動作確認

masterブランチ

今までの作業を master ブランチにコミットし、GitLabにpushします。

すると、以下の画像のように、Lintとpdfの生成ができました。

右側にあるダウンロードボタンを押すことで、Re:VIEWとpdfのファイルがzip化されたものがダウンロードできます。

f:id:thinkAmi:20190326223859p:plain

 

featureブランチ

次に、Lintエラーが出るように以下を追加した target.md をfeatureブランチにcommitします。

- python
- PYTHON

 
GitLabにpushしてCIを確認すると、Lintチェックエラーになりました。

f:id:thinkAmi:20190326224408p:plain

また、後続のpdf作成ジョブは実行されていませんでした。

f:id:thinkAmi:20190326224054p:plain  
 

lint-onlyブランチ

最後に、Lintエラーが出るように以下を追加した target.mdlint-only ブランチにcommitします。

- Lintだけします
  - python

GitLabにpushしてCIを確認すると、Lintチェックエラーになりました。

f:id:thinkAmi:20190326224738p:plain

 
また、今までと異なり、後続ジョブ pdf_build がありません。

f:id:thinkAmi:20190326224755p:plain  

以上で、GitLab CI + docker-reviewを使って、Markdownをtextlintしてからpdf化するCI環境ができました。

#stapy #glnagano みんなのPython勉強会 in 長野#3に参加しました

3/23にギークラボ長野で開かれた「みんなのPython勉強会 in 長野 #3」に参加しました。
みんなのPython勉強会 in 長野 #3 - connpass

今回は、トークセッションともくもく会の2本立てでした。資料は上記のconnpassにまとまっています。

長野県内はもちろん、東京・名古屋、そしてはるばる石川から参加された方など、大勢の参加者で会場が埋まりました。

以下、簡単な感想とメモです。

 

イントロダクション

阿久津 剛史さん

資料:みんなのPython勉強会 in 長野 #3, Intro

みんなのPython勉強会について、過去の長野巡業からコミュニティの紹介がありました。毎年長野巡業があり、ありがたい限りです。

 

Pythonを知ろう!

辻 真吾さん

資料:みんなのPython勉強会スライド の中にある みんなのPython勉強会 in 長野#3

Pythonの概要から最近の話題までがまとめられていました。

特に、あまり追っていなかった、PEP572の概要とPython運営委員会の話もあり、理解が深まりました。

なお、運営委員会の選挙結果は、PEP8100にまとまっているようです。
PEP 8100 -- January 2019 steering council election | Python.org

 

PythonRaspberry Pi、SORACOMでお手軽IoTをしましょう

知野 雄二さん

資料:Python、Raspberry Pi、SORACOMでお手軽IoTをしましょう

SORACOMを中心に、Raspberry PiAWSGCPなどのクラウドとどうつないでいくかという発表でした。

IoT方面は疎いので、何か作りたいなーと思ったら参考にしたり質問してみたいと思いました。

 

社内WebシステムのPython活用秘話

滝野 優紀さん

弊社事例でした。

いろいろWeb化されているので、リモートワークもしやすいというのは確かに感じています。自分が入社した時よりも、最近はもっと気軽にリモートワークできている雰囲気がありますし。

なお、事例の中で触れられていませんでしたが、ソースコードは社内GitLabで公開されています*1

 

神エクセル課題解決の第一歩、統計ExcelCSV

前川 道博さん

信州デジタルコモンズ/統計オープンデータへのいざない まわりについて、統計データの公開に対する自治体の違い、公開されている神Excel、開発したアプリなどの紹介がありました。

アプリのソースコードは、以下のリポジトリにて公開されています。 Tanukium/msemi: msemi 2018

 
お話を聞いて、自治体まわりではデータが公開されているだけでもマシなんだなと改めて思いました。

また、公開されるオープンデータの形式は CSV が標準というのを知ることができてよかったです。

 

PandasとDjango Girls Tutorial Extensionsについて

nikkieさん

最近Pandasをさわっていたこともあり、トーク中で紹介されていた Pandasクックブック が良さそうに感じ、買いました。
朝倉書店| pandasクックブック ―Pythonによるデータ処理のレシピ―

また、Django Girls Tutorial Extensionsがあることも知ることができてよかったです。

なお、日本語翻訳版については、現在本家へPR中とのことです。

 

 

LTと懇親会

食べながらだったためメモは残せませんでしたが、いろいろな分野のお話を聞けました。

  • Pythonもくもく会 in 名古屋 #3 - connpass をはじめとした、名古屋支部の様子とこれからについて
    • わいわい会よさそう
  • デコレータとDiscordについて
    • Discordに色々なチャンネルがあることを初めて知った
  • 公開されている犯罪統計データExcelを扱うお話
    • ここでも神Excel...
  • USB接続のGoogle Edge TPUを使って、リアルタイム顔認識のデモ
    • USB接続のGoogle Edge TPU、ガジェットに加わるといいなぁ |ω・)チラッ
  • 将棋まわりのいろいろなライブラリを開発しているお話
    • 将棋倶楽部24のいろいろなデータを活用しようとしてるのがすごい

 

その他

ここ最近自分が技術的に悩んでいたことを相談してみたところ、さくっと回答をいただけました。

他にも、懇親会などでいろいろとお話できたのが良かったです。

独学で詰まった時には外に出ていろいろとお話するのが大事だと、改めて感じました。

 

最後になりましたが、企画・運営・参加をされたみなさま、ありがとうございました。

*1:自分は特に不満なく使っているのでコントリビュートできていない...

Python3.4 & Django1.8な個人アプリを、Python3.7 & Django 2.1 へとアップデートした

以前、食べたリンゴの割合をグラフ化するHerokuアプリを作りました。
Python + Django + Highcharts + Herokuで食べたリンゴの割合をグラフ化してみた - メモ的な思考的な

 
Python3.4 & Django1.8な環境で作成した2015年以降、食べたりんごの種類を記載していたYAMLファイル以外は、ノーメンテナンスで過ごしてきました。

ただ、最近はパフォーマンスが悪化し、見るのがつらい状況でした。

 
メンテナンスをしようかと思いましたが、

  • Djangoにさわり始めた頃のアプリのため、Djangoプロジェクト構成があまりよくない
  • テストコードなし
  • Windowsの開発環境を無くした

などがあり、あまり気が進まない状況でした。

 
そんな中、Django2.1系のPythonサポートを見たところ、Python3.4系では動作しないことに気づきました。
FAQ: インストール | Django documentation | Django

 
さすがに今後使えなくなるのはマズイため、Python3.7系 & Django2.1系へとアップデートしました。

今回はその時のメモになります。

 
目次

 

環境

 

アップデート方針

PythonDjangoを最新化するに伴い、各種ライブラリや開発環境もアップデートすることにしました。

主な作業は以下でした。

  • 開発環境のDBとしてDockerを利用
  • テストコードを作成
  • 基盤を最新版 (Python 3.7.2 & Django 2.1.5) へアップデート
    • 合わせて、各種ライブラリも最新化
  • Djangoっぽいアプリケーション構成へと修正
  • jQuery + getJsonでの非同期データ取得を、Fetch API + async/await へと修正
  • パフォーマンスの改善

 
以降、各項目の内容を記載していきます。なお、記載する順番は上記と異なります。

 

開発環境のDBとしてDockerを利用

このアプリでは、DBとしてHeroku Postgresを使っています。また、集計処理ではPostgreSQLの独自関数を使っています。

そのため、開発環境にもPostgreSQLを用意する必要がありました。ただ、開発環境にはすでにPostgreSQLがいる & その環境を壊したくありませんでした。

そこで今回は、開発環境のPostgreSQLをDockerで用意することにしました。

 
まずは本番環境であるHeroku Postgresのバージョンを調べたところ、最新は10系でした。
Version support and legacy infrastructure | Heroku Postgres | Heroku Dev Center

 
そこで、PostgreSQL 10.6 のDockerイメージを使い、開発環境を構築しました。

# デフォルトのポート 5432はすでに使われているので、別のポート(19876)をDocker上の 5432 につなげる
$ docker run --name ringo_pg -p 19876:5432 -e POSTGRES_USER=ringo -e POSTGRES_PASSWORD=postgres -d postgres:10.6

# コンテナ起動
docker start ringo_pg

# データベースを作成
psql -U ringo -W -p 19876 -h localhost -c "CREATE DATABASE ringo_tabetter_py;"

 
次に、Heroku Postgresのデータをローカルに取り込みます。

# ローカルに ringo-tabetter のGitリポジトリを作成
$ git clone git@github.com:thinkAmi/dj_ringo_tabetter.git .

# settings.pyのDATABASESのポート番号を修正するのを忘れずに!!

# ローカルのDBに対し、マイグレーション
$ python manage.py migrate

# Heroku Postgresのデータを、ローカルの output.sql ファイルとしてダウンロード
# usernameやdbnameは、Heroku Postgresのページにて確認
$ pg_dump --host=ec2-xxx.compute-1.amazonaws.com --port=5432 --username=xxx --password --dbname=yyy > output.sql

# リストア
$ psql -d ringo_tabetter_py -h localhost -p 19876 -U ringo -f output.sql

参考:sql - How can I get a plain text postgres database dump on heroku? - Stack Overflow

 
ローカルで runserver したところ、問題なく動作しました。

 

テストコードを作成

Django1系から2系にアップデートすることもあり、まずはテストコードを整備しようと考えました。

ただ、ツイートを読み込んで集計・表示するだけのアプリという性格上、テストコードは正常系だけにしました。

また、テストについては

としました。

 
なお、テストメソッド名を日本語にすると、PyCharmでは怒られている気分になります。

f:id:thinkAmi:20190215000307p:plain:w300

 
そこで、以下の設定を行い、ちょっと怒られているだけにしてみました。

Editor > Inspections > Internationalization - Non-ASCII characters のチェックを外す

f:id:thinkAmi:20190215000325p:plain:w300

 
もし、より良い方法をご存知でしたら、教えていただけるとありがたいです。

 

テンプレートを表示するだけのViewのテスト

ステータスコードを見ているだけです。

テスト対象のURLを @pytest.mark.parametrize で渡していますが、単に使いたかっただけですので、深い意味はありません。

class TestViewForHighcharts:
    """ HighchartsのViewテスト """

    @pytest.mark.parametrize('url, expected_status_code', [
        (reverse('highcharts:total'), 200),
        (reverse('highcharts:total_by_month'), 200)
    ])
    def test_it(self, client, url, expected_status_code):
        actual = client.get(url)
        assert actual.status_code == expected_status_code

 

JSONをレスポンスするViewのテスト

どんな形のJSONが返ってくるのか忘れていたため、JSON文字列自体をハードコーディングしました。

また、使い回しが聞くように、 pytest.fixture で定義しておきました。

@pytest.fixture
def total_apples_expected():
    for i in range(3):
        TweetsFactory(name='フジ')
    for i in range(2):
        TweetsFactory(name='シナノドルチェ')
    for i in range(5):
        TweetsFactory(name='シナノゴールド')

    return '''[
  {
    "name": "シナノドルチェ",
    "y": 2,
    "color": "AntiqueWhite"
  },
  {
    "name": "シナノゴールド",
    "y": 5,
    "color": "Gold"
  },
  {
    "name": "フジ",
    "y": 3,
    "color": "Red"
  }
]'''

 
あとは、レスポンスボディに対して、期待したJSONかのassertするテストを書きました。

エラーが起きたらassertで落ちるだろうと思い、ステータスコードチェックは省略しました。

@pytest.mark.django_db(transaction=True)
class TestTotalApples:
    """ total_apples() のテスト """

    def test_get(self, client, total_apples_expected):
        actual = client.get('/api/v1/total/')
        assert actual.content.decode('utf-8') == total_apples_expected

 
なお、テスト対象のURLは、上記のような reverse() は使わずにハードコーディングしています。

テスト中のURLはどちらで書くのが一般的なのか、ご存知でしたら教えていただけるとありがたいです。

 

Djangoカスタムコマンドのテスト

Djangoカスタムコマンドについては、2種類のテストを書きました。

1つは、Djangoコマンド内部で呼ばれている各メソッドのテストです。

fixtureやfactory-boy、pytestプラグインなどを使ってテストを書きました。

Status = namedtuple('Status', ('id', 'text', 'created_at'))


@pytest.fixture
def cultivars():
    return [
        {'Name': 'シナノゴールド', 'Color': 'Gold'},
        {'Name': 'シナノドルチェ', 'Color': 'Red'},
        {'Name': '王林', 'Color': 'Yellow'},
    ]


@pytest.mark.freeze_time("2019-01-01")
@pytest.mark.django_db(transaction=True)
class TestGatherTweets:
    def test_save_with_transaction_該当ツイート無し_LastSearchありの場合(self, cultivars):
        from apps.tweets.management.commands.gather_tweets import Command
        from apps.tweets.models import Tweets, LastSearch

        sut = Command()
        sut.cultivars = cultivars
        sut.last_search = LastSearchFactory()

        # Twitterの仕様では、新しいものから順に取得できる(添字が小さいほど、最近の投稿になる)
        statuses = [
            Status(id=3, text='リンゴ 今日は `シナノゴールド` を食べた', created_at=datetime.now()),
            Status(id=2, text='[リンゴ] 今日はシナノゴールドを食べた', created_at=datetime.now()),
            Status(id=1, text='[りんご] 今日は `シナノゴールド` を食べた', created_at=datetime.now()),
        ]
        sut.save_with_transaction(statuses)

        assert Tweets.objects.count() == 0

        actual = LastSearch.objects.all()
        assert len(actual) == 1
        assert actual.first().prev_since_id == 3

 
もう1つは、Djangoカスタムコマンド自体を実行して、期待する動作をしているかのテストです。
testing - How to test custom django-admin commands - Stack Overflow

こちらはテストデータを用意するのが手間でした。そのため、モックを使って、想定したメソッドが呼ばれているかをチェックするようなテストを書きました*1

def test_call_command_Djangoコマンドを直接呼ぶ(self):
    from apps.tweets.management.commands import gather_tweets

    with patch.object(
            gather_tweets, 'Apple', return_value=Mock()) as apple, \
        patch.object(
            gather_tweets.Command, 'get_last_search') as mock_get, \
        patch.object(
            gather_tweets.Command, 'gather_tweets', return_value='foo') as mock_gather, \
        patch.object(
            gather_tweets.Command, 'save_with_transaction') as mock_save:

        # 作成したDjangoカスタムコマンドを直接呼ぶ
        call_command('gather_tweets')

        # コマンドの内部で呼ばれるはずのメソッドが、想定通り呼ばれているかをチェック
        mock_get.assert_called_with()
        mock_gather.assert_called_with()
        mock_save.assert_called_with('foo')  # mockで foo を返しているので、それが使われるか

 

TwitterやSlack APIのテスト

このアプリでは以下を行っていました。

  • Twitter APIを使って、ツイートを取得
  • Slack APIを使って、エラー情報をポスト

 
ただ、pytestを実行すると毎回APIを呼んでしまうのは良くなさそうです。

そのため、以前の記事を参考にしつつ、pytestのコマンドラインオプションで指定された場合のみ、テストを実行するようなfixtureを conftest.py に書きました。
Python + pytestにて、pytestに独自のコマンドラインオプションを追加する - メモ的な思考的な

import pytest

def pytest_addoption(parser):
    parser.addoption('--twitter', action='store', type=int,
                     help='Twitterのテストを実行する(要: Twitterの設定)。値は取得を開始する status_id。'
                     '最新の status_id だとテストがコケるので、3ツイートよりも前の status_id をセットする'
                     )

@pytest.fixture(scope='session')
def twitter(request):
    status_id = request.config.getoption('--twitter')
    if not status_id:
        pytest.skip()
    return status_id

 

基盤を最新版へアップデート

テストコードができたので、PythonDjangoを最新版へアップデートします。

手元の環境には pyenv が入っていたので、Pythonのバージョンを変更した上で、再度 venv で仮想環境を作成しました。

その後、各ライブラリの最新版をインストールしました。

 

Djangoを1.8.5から2.1.5へアップデートした後の対応

Django1系から2系への切替なので、いくつか不具合が発生しています。

テストコードを実行しつつ、その不具合を1つずつ潰していきます。

コミットはこのあたりです。
https://github.com/thinkAmi/dj_ringo_tabetter/commit/ed3d5ee86fa0f67d5129331ed29097a725ee6873

 

urls.pyの修正

Django1.8系では次のような書き方でした。

urlpatterns = patterns('',
                       url(r'^v1/total/$', views.total_apples),
                       url(r'^v1/month/$', views.total_apples_by_month),
                       ) 

 
Django2.1.5では、

  • patterns()url() が廃止
  • app_name が必要

なため、以下のように修正しました。

app_name = 'api'

urlpatterns = [
    path('v1/total/', views.total_apples, name='total'),
    path('v1/month/', views.total_apples_by_month, name='total_by_month'),
]

 

また、Djangoプロジェクトで admin ページのURL設定も変更となっていました。

# 1.8.5
url(r'^admin/', include(admin.site.urls)),

# 2.1.5
path('admin/', admin.site.urls),

 
なお、 include() まわりは、過去記事を参照して修正しています。
Django2.0のプロジェクトのurls.pyにおける、include()での引数namespaceについて調べてみた - メモ的な思考的な

 

settings.pyの修正

修正量が多いような気がしたので、

  • 別環境に、キレイなDjango 2.1.5 プロジェクトを作成
  • 上記で作成した settings.py に、Django1.8.5のsettingsの内容を反映

という手段を取りました。

テストがあることと、たいしたアプリではないことから、思い切ってやれました。

大きな変化は、

  • MIDDLEWARE_CLASSES が廃止、 MIDDLEWARE へと変更
    • MIDDLEWARE の先頭に、django.middleware.security.SecurityMiddleware を指定
  • AUTH_PASSWORD_VALIDATORS が新設

あたりでした。

 

Whitenoise v4.0の破壊的変更に対応

DjangoをHerokuで使う時の静的ファイルの配信に、 Whitenoise を使っていました。

ただ、v4.0より破壊的変更が入りました。
http://whitenoise.evans.io/en/stable/changelog.html#v4-0

 
そのため、v4.0以降でも動作するよう、以下の修正を行いました。

  • ミドルウェアwhitenoise.middleware.WhiteNoiseMiddleware を追加
  • settings.pyで STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' に差し替え
  • wsgi.pyの以下の設定を削除
from whitenoise.django import DjangoWhiteNoise
application = DjangoWhiteNoise(application)

 

render_to_response() を render() に差し替え

実行ログを見ていると、以下のような RemovedInDjango30Warning が出ていました。

RemovedInDjango30Warning: render_to_response() is deprecated in favor of render(). It has the same signature except that it also requires a request.

 
そのため、 render_to_response() の箇所を render() に変えました。

両者の関数の違いは、 request オブジェクトの有無が大きいようです。
Django - Why should I ever use the render_to_response at all? - Stack Overflow

 

docstring や type hints の整備

細かいところですが、今までの書き方では情報が不足していたため追加しました。

なお、APIの戻り値など、動的に型を生成しているところは、type hints を記述しませんでした。

こんな感じです。

from typing import List

def render_json_response(request, data: List[dict], status=None) -> HttpResponse:
    ...

 

リファクタリング

テストがパスすることを確認しながら今までの作業を行ってきました。

そこで次は、リファクタリングをして、よりメンテナンスしやすい作りにします。

主な作業は以下のコミットにまとまっていますが、やったことを簡単にメモしていきます。
https://github.com/thinkAmi/dj_ringo_tabetter/commit/71d11ea7f3ada8b39c678451c010630031abe6ce

 

表示パフォーマンスの改善

動作が重くて仕方なかったため、まずはパフォーマンス改善に着手しました。

怪しいと思っていた箇所は2つです。

  • DB側で集計処理をするところ
  • DBから取得したデータをHighchartsで処理するところ

 
計測してみたところ、DBの集計処理は特に問題ありませんでした。一方、Highchartsの方は問題でした。

問題があったのは、以下の作りです。

$.getJSON('/api/v1/total', function(res){
    $.each(res, function (i, json) {
        chart.series[0].addPoint({
            name: json['name'],
            y: json['quantity'],
            color: json['color']
        });
    });
});

取得したJSONデータに含まれる配列を $.each() して、 addPoint() でチャートに追加していました。

 
そこで、 $.each()addPoint() するのではなく、取得したJSONを直接設定するように変更したところ、劇的に改善しました。

series: [{
    data: jsonData
}]

 

libs.cultivars を apps に移動

libs.cultivars は各Djangoアプリから呼び出すために作ったライブラリです。

ただ、Djangoプロジェクトの作法に従い、Djangoアプリケーションとして扱うようにしました。

以下の方法で、新規 Djangoアプリケーションを作成し、その中に移動しました。
windows - Django, can't create app in subfolder - Stack Overflow

# ディレクトリを作る
mkdir ./apps/cultivars

# startappする
$ python manage.py startapp cultivars ./apps/cultivars

 

Djangoっぽいアプリ化

Djangoアプリとしては、他にも以下の点が気になったので、修正しました。

 
また、趣味の範囲ですが、以下も行いました。

  • JSONを返すViewをクラスベースView化
    • Viewを継承して作成

 

トップページにアクセスした時のリダイレクトを追加

トップページにアクセスした場合、今まではエラーになっていました。URLを忘れてしまった時など、意外とつらかったです。

そこで、トップページにアクセスした場合は、Highchartsの集計ページへリダイレクトするよう、Djangoプロジェクトの urls.py に追加しました。

urlpatterns = [
    ...
    # トップにアクセスした時は、Highchartsの合計ページへリダイレクト
    path('', RedirectView.as_view(url=reverse_lazy('highcharts:total'))),
]

 

GitHubのSecurity alertへ対応

GitHubを見たところ、CVE-2017-18342 のSecurity alertが出ていました。
https://github.com/thinkAmi/dj_ringo_tabetter/network/alert/requirements.txt/pyyaml/open

 
PyYAMLのGitHubを見ると、すでにいくつかのissueとコメントがありました。

 
そのコメントより、 yaml.load() ではなく yaml.safe_load() を使えば良いとのことだったため、差し替えを行いました。

 
とはいえ、GitHub上からはalertが消えないため、PyYAMLの次のバージョンのリリースがあるといいなという状況です...

 

django-jinjaへの依存を削除

このアプリを作った当初は Jinja2 テンプレートがDjangoでは扱えなかったため、 django-jinja を追加して使っていました。

 
その後、Djangoでも Jinja2 テンプレートを扱えるようになり、Django2系からは context_processors の指定も可能になりました。
https://docs.djangoproject.com/en/2.1/topics/templates/#django.template.backends.jinja2.Jinja2

 
そこで今回、django-jinjaへの依存を削除してみました。

settings.pyの TEMPLATES では、以下を行いました。

  • BACKEND を差し替え
  • match_extension を削除
  • environment を追加
TEMPLATES = [
    {
        # 差し替え
        # 'BACKEND': 'django_jinja.backend.Jinja2',
        'BACKEND': 'django.template.backends.jinja2.Jinja2',
        
        'DIRS': [os.path.join(BASE_DIR, 'templates')],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
            
            # 削除
            # 'match_extension': '.jinja2',
            
            # 追加
            'environment': 'dj_ringo_tabetter.jinja2.environment',
        }
    },
]

 
次にDjangoプロジェクトディレクトリ (dj_ringo_tabetter/) の中に、 jinja2.py を作成します(上記の environment で指定したファイル)。

そのファイルに、Jinja2の設定を行います。何もしないと static などのテンプレートタグが扱えないためです。

作業としては、必要なテンプレートタグを jinja2.py の env.globals に追加します。

なお、テンプレート側では、 {% load static %} などの記述は不要です。

from django.templatetags.static import static
from django.urls import reverse

from jinja2 import Environment


# Djangoのtemplatetagsを利用できるように設定
# https://docs.djangoproject.com/en/2.1/topics/templates/#django.template.backends.jinja2.Jinja2
def environment(**options):
    env = Environment(**options)
    env.globals.update({
        'static': static,
        'url': reverse,
    })
    return env

 

$.getJson()から、async/awaitなfetch()へと変更

Fetch API & async/awaitを使うことで、jQueryなしに非同期でJSONを取得する処理が書けます。

基本的にモダンブラウザでしか見ないので、今回修正してしまいます。

修正前

$.getJSON('/api/v1/total', function(res){
    // ...
});

修正後

const renderChart = async (url) => {
    const res = await fetch(url);
    return await res.json();
};

renderChart('/api/v1/month')
    .then(jsonData => {
        // ...
    });

参考:イマドキのJavaScriptの書き方2018 - Qiita

 

その他JavaScriptまわりの修正
  • BootstrapはCSSだけ使うだけでも十分だったため、jQueryへの依存も削除
  • BootstrapとHighchartsはCDNではなく、手元に保存したものを見るように変更

 

Heroku向けの設定変更

いくつかHeroku向けの設定を変更しました。

  • settings.py
    • ALLOWED_HOSTS = ['127.0.0.1', 'ringo-tabetter.herokuapp.com']
      • ローカルとHeroku上、両方ともアクセスできるようにするため
  • runtime.txt
    • python-3.7.2
      • Python3.7.2で動作させるため

 

Herokuへデプロイする準備

MacではHeroku経でプロイする準備をしていなかったため、セットアップを行いました。

まずは、以下の内容に従い、Heroku CLIをインストールします。 https://devcenter.heroku.com/articles/heroku-cli

今回は、homebrewでインストールしました。実行後、以下が表示され、問題なくインストールできました。

To use the Heroku CLI's autocomplete --
  Via homebrew's shell completion:
    1) Follow homebrew's install instructions https://docs.brew.sh/Shell-Completion
        NOTE: For zsh, as the instructions mention, be sure compinit is autoloaded
              and called, either explicitly or via a framework like oh-my-zsh.
    2) Then run
      $ heroku autocomplete --refresh-cache

  OR

  Use our standalone setup:
    1) Run and follow the install steps:
      $ heroku autocomplete

Bash completion has been installed to:
  /usr/local/etc/bash_completion.d

zsh completions have been installed to:
  /usr/local/share/zsh/site-functions

 
続いて、Heroku CLIからログインします。

実行後、何かキーを押すとブラウザが開くので、ブラウでログインすればOKでした。

$ heroku login

 

次に、git remoteを追加します。

$ heroku git:remote -a ringo-tabetter
set git remote heroku to https://git.heroku.com/ringo-tabetter.git

 
念のため、git remoteを確認します。

$ git remote -v
heroku  https://git.heroku.com/ringo-tabetter.git (fetch)
heroku  https://git.heroku.com/ringo-tabetter.git (push)
origin  https://github.com/thinkAmi/dj_ringo_tabetter (fetch)
origin  https://github.com/thinkAmi/dj_ringo_tabetter (push)

 
これで準備が整いました。

今回はブランチを別にして開発していたため、以下を参考にHerokuへブランチをpushしました。
https://devcenter.heroku.com/articles/git#deploying-from-a-branch-besides-master

git push heroku feature/migrate-to-django2:master

 
ただ、以前のアプリはCeder-14を使っていたため、Python3.7系のアプリがデプロイできませんでした。
HerokuのCedar-14に、Python3.7系アプリをデプロイしたらエラーになった - メモ的な思考的な

 
そこで、上記の記事にある通り、HerokuのStackをアップデートしたところ、無事にデプロイできました。

 
以上が今回行った作業となります。

Heroku上のアプリが寝ている時以外は快適であり、個人的に満足しています。
https://ringo-tabetter.herokuapp.com/

f:id:thinkAmi:20190214235021p:plain

 

今回やらなかったこと

今のところはまだいいかなと考え、以下の内容は行いませんでした。

  • プロジェクトディレクトリ名を config などに修正すること
  • settings.pyを、開発用・本番用などに分割すること

 

ソースコード

GitHubに上げました。今回の修正したブランチ feature/migrate-to-django2 は残してあります*2
https://github.com/thinkAmi/dj_ringo_tabetter

*1:モックを使いたかっただけかもしれません...

*2:PyYAMLのCVE-2017-18342への対応は、気づくのが遅れたため、このブランチには含まれていません

HerokuのCedar-14に、Python3.7系アプリをデプロイしたらエラーになった

昔作成したHerokuアプリのPythonが3.4系だったため、Python3.7系で動作するようにアプリを修正し、Herokuへデプロイしたところ、

$ git push heroku feature/migrate-to-django2:master
Enumerating objects: 171, done.
Counting objects: 100% (171/171), done.
Delta compression using up to 4 threads
Compressing objects: 100% (130/130), done.
Writing objects: 100% (148/148), 420.86 KiB | 2.36 MiB/s, done.
Total 148 (delta 59), reused 1 (delta 1)
remote: Compressing source files... done.
remote: Building source:
remote:
remote: -----> Python app detected
remote: -----> Found python-3.4.3, removing
remote: -----> Installing python-3.7.2
remote: -----> Installing pip
remote: -----> Installing requirements with pip
remote:  !     Push rejected, failed to compile Python app.
remote:
remote:  !     Push failed
remote: Verifying deploy...
remote:
remote: !   Push rejected to ringo-tabetter.
remote:
To https://git.heroku.com/ringo-tabetter.git
 ! [remote rejected] feature/migrate-to-django2 -> master (pre-receive hook declined)

とエラーになりました。

 
その時に対応した内容をメモします。

 

目次

 

環境

 

対応

heroku logs コマンドでログを見ましたが、特に有用な情報はありませんでした。

$ heroku logs
...
app[web.1]: [INFO] Worker exiting (pid: 8)
app[web.1]: [INFO] Worker exiting (pid: 9)
app[web.1]: [INFO] Handling signal: term
app[web.1]: [INFO] Shutting down: Master
app[api]: Build failed -- check your build output: https://dashboard.heroku.com/apps/xxx/activity/builds/xxx

 
check your build output のURLを確認しましたが、pushした時のログと同じで、手がかりにはなりませんでした。

 

そんな中、Herokuアプリの情報を見ていたところ、そのアプリはCedar-14を使っていました。また、 upgrade のリンクもありました。

Herokuのヘルプを見たところ、Cedar-14はUbuntu14.04ベースであり、deprecatedになっていました。
Stacks | Heroku Dev Center

もしかしたら、deprecatedな環境だとPython3.7系は用意されていないのかもしれないと思い、Stackをアップグレードすることにしました。

 
そこで、Dashboardにて upgrade をクリックしたところ、表示が

Stack heroku-18 will replace cedar-14 on the next deploy

に変わりました。

 
次に、再度同じブランチをpushしたところ、問題なく完了しました。

$ git push heroku feature/migrate-to-django2:master
...
remote: Verifying deploy... done.
To https://git.heroku.com/ringo-tabetter.git

 
古いStackを使っていたのがダメだったようです。

ただ、公式ドキュメントによると、Cedar-14でPython3.7.2は使えるようです。何かのタイミングでしょうか... https://devcenter.heroku.com/articles/python-support#supported-runtimes

pytestを4系にアップデートしたら、pytest-freezegun 0.2.0 でエラーが出た

Pythonで日付まわりをテストする場合、日付を固定できる freezegun が便利です。
spulec/freezegun: Let your Python tests travel through time

 
また、pytestの場合、pytestのプラグインとして pytest-freezegun があります。
ktosiek/pytest-freezegun: Easily freeze time in pytest test + fixtures

 
これを使うことで、pytestのmarkerとして @pytest.mark.freeze_time が追加されます。

そのため、markerを使うだけで、現在時刻が固定されます。

from datetime import datetime
import pytest

@pytest.mark.freeze_time('2018-02-03 1:23:45')
def test_time():
    assert datetime.today() == datetime(2018, 2, 3, 1, 23, 45)

 
実行結果

$ pytest
==== test session starts ====
platform darwin -- Python 3.7.2, pytest-3.7.4, py-1.7.0, pluggy-0.8.1
rootdir: /path/to/dir, inifile:
plugins: freezegun-0.2.0
collected 1 item

test_time.py .    [100%]

==== 1 passed in 0.07 seconds ====

 
そんな中、pytestを3.7.4から4.1.1へとアップデートしたところ、エラーが出ました。

# バージョン確認
$ pip list
Package          Version
---------------- -------
pytest           3.7.4
pytest-freezegun 0.2.0  

# アップデート
$ pip install -U pytest
...
Successfully installed pytest-4.2.0

# テストを実行すると、エラー
$ pytest
===== test session starts ====
platform darwin -- Python 3.7.2, pytest-4.2.0, py-1.7.0, pluggy-0.8.1
rootdir: /path/to/dir, inifile:
plugins: freezegun-0.2.0
collected 1 item

test_time.py E    [100%]

==== ERRORS ====
____ ERROR at setup of test_time ____

self = <pytest_freezegun.FreezegunPlugin object at 0x10dbea588>, item = <Function test_time>

    @pytest.hookimpl(tryfirst=True)
    def pytest_runtest_setup(self, item):
>       marker = item.get_marker('freeze_time')
E       AttributeError: 'Function' object has no attribute 'get_marker'

env/lib/python3.7/site-packages/pytest_freezegun.py:23: AttributeError

 
そこで、対応したことをメモとして残します。

 

環境

  • Python 3.7.2
  • pytest 3.10.1 からpytest 4.1.1 へアップデート
  • pytest-freezegun 0.2.0

 

対応

公式にissueがありました。pytest-freezegun の新しいバージョンで対応したようです。
pytest4.0 released and pytest-freezegun can't work. · Issue #7 · ktosiek/pytest-freezegun

 
そのため、pytest-freezegunをアップデートしたところ、問題なく動作するようになりました。

$ pip install -U pytest-freezegun
...
Successfully installed pytest-freezegun-0.3.0.post1

# テストを再実行
$ pytest
==== test session starts ====
platform darwin -- Python 3.7.2, pytest-4.2.0, py-1.7.0, pluggy-0.8.1
rootdir: /path/to/dir, inifile:
plugins: freezegun-0.3.0.post1
collected 1 item

test_time.py .    [100%]

==== 1 passed in 0.08 seconds ====

macOS + postgresqlでエラー「dyld: Library not loaded」が出た

macOS + PostgreSQLで環境構築したところ

$ psql
dyld: Library not loaded: /usr/local/opt/readline/lib/libreadline.7.dylib
  Referenced from: /usr/local

が出たため、対応した時のメモです。

 
目次

 

環境

  • macOS 10.13.6 (High Sierra)
  • Homebrewで postgres をインストール済
    • インストール済のpostgresは、10.3

 

調査

エラーメッセージを見ると、ライブラリがなさそうでした。

念のため確認してみたところ、確かにありませんでした。

$ ls -al /usr/local/opt/readline/lib
total 1448
drwxr-xr-x  11 shinano_gold  ringo     352 12 20 06:07 .
drwxr-xr-x  12 shinano_gold  ringo     384  1 28 09:24 ..
-r--r--r--   1 shinano_gold  ringo   40396  1 28 09:24 libhistory.8.0.dylib
lrwxr-xr-x   1 shinano_gold  ringo      20 12 20 06:07 libhistory.8.dylib -> libhistory.8.0.dylib
-r--r--r--   1 shinano_gold  ringo   45880 12 20 06:07 libhistory.a
lrwxr-xr-x   1 shinano_gold  ringo      20 12 20 06:07 libhistory.dylib -> libhistory.8.0.dylib
-rw-r--r--   1 shinano_gold  ringo  239252  1 28 09:24 libreadline.8.0.dylib
lrwxr-xr-x   1 shinano_gold  ringo      21 12 20 06:07 libreadline.8.dylib -> libreadline.8.0.dylib
-r--r--r--   1 shinano_gold  ringo  405848 12 20 06:07 libreadline.a
lrwxr-xr-x   1 shinano_gold  ringo      21 12 20 06:07 libreadline.dylib -> libreadline.8.0.dylib
drwxr-xr-x   3 shinano_gold  ringo      96  1 28 09:24 pkgconfig

 
そのため、Homebrewでインストールするpostgresの依存関係を見てみました。
https://formulae.brew.sh/formula/postgresql@10

すると、

readline ✅ 8.0.0   Library for command-line editing

と、手元の readline 8.0 で動作しそうでした。

 
また、Homebrewにある最新の postgres のバージョンを確認すると、11.1 でした。
https://formulae.brew.sh/formula/postgresql

 

対応

brew switch で readline のバージョンを切り替えることも考えました。

ただ、postgresのバージョンを上げても問題ないだろうと考え、 brew upgrade しました。

$ brew upgrade postgresql

 
その後はエラーが発生しなくなりました。

Python + Zeep にて、SOAPの wsi:swaRef でファイルを送信する

前回、swaRef にて、SOAPでファイルを送信してみました。
Python + Zeep にて、SOAPのswaRef でファイルを送信する - メモ的な思考的な

 
今回は、wsi:swaRefという仕様でファイルを送信してみます。

 
なお、今回扱うwsi:swaRefについてですが、SOAPエンベロープは、

<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
  <soap-env:Body>
    <ns0:RequestInterface xmlns:ns0="http://example.com/HelloWorld">
      <ns0:image>cid:image=spam</ns0:image>
    </ns0:RequestInterface>
  </soap-env:Body>
</soap-env:Envelope>

のように <ns0:image>cid:image=spam</ns0:image> と、プレフィックス cid: を付与する形となります。

また、動作確認は SOAP UI を使っているため、もしかしたらそれ以外の環境では動作しないかもしれません。

 
目次

 

環境

 

実装するもの

です。

Transportについては、SwAの実装を流用できますので、今回は省略します(後述のGitHubにはTransportも実装してあります)。

 

WSDLの実装

今回は image elementの型として ref:swaRef を指定します。

そのため、

  • 名前空間 ref の追加
  • image elementの型を ref:swaRef と定義

をします。

 
ただ、名前空間を追加してZeepを実行すると

zeep.exceptions.NamespaceError: Unable to resolve type {http://ws-i.org/profiles/basic/1.1/xsd}swaRef. No schema available for the namespace 'http://ws-i.org/profiles/basic/1.1/xsd'.

というエラーが発生します。

 
試しにcurlを使って名前空間のページにアクセスしてみると

$ curl http://ws-i.org/profiles/basic/1.1/xsd -L
The page cannot be displayed because an internal server error has occurred.

とエラーになりました。

これにより、定義が見つからないために、Zeepがエラーを出していることが分かりました。

 
どこかに定義がないかを探してみると、仕様書の「4.4 Referencing Attachments from the SOAP Envelope」に記載がありました。

As a convenience, WS-I has published the schema for this schema type at: http://ws-i.org/profiles/basic/1.1/swaref.xsd

http://www.ws-i.org/Profiles/AttachmentsProfile-1.0-2004-08-24.html#Referencing_Attachments_from_the_SOAP_Envelope

 
指定されたURLを開くと、XMLスキーマがありました。

ただ、URLが微妙に異なっているため、WSDLの内容を差し替えてみました。

ただ、それでも同じエラーが発生しました。

No schema available for the namespace 'http://ws-i.org/profiles/basic/1.1/swaref.xsd'.

 
何か良い方法がないかを探したところ、スキーマを import する方法がありました。
XML Schemaのインポート

 
そこで、

$ curl http://ws-i.org/profiles/basic/1.1/swaref.xsd > wsi_swa_ref.xsd
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  4058  100  4058    0     0  11694      0 --:--:-- --:--:-- --:--:-- 11728

curlxmlwsi_swa_ref.xsd として取得します。

 
次に、取得したxsdファイルをimportします。

<wsdl:types>
    <xsd:schema>
        <xsd:import namespace="http://ws-i.org/profiles/basic/1.1/xsd"
                    schemaLocation="wsi_swa_ref.xsd" />
    </xsd:schema>
    <xsd:schema elementFormDefault="qualified" targetNamespace="http://example.com/HelloWorld">
    ...

 
これにより、型 ref:swaRef が使えるようになったため、型を差し替えました。

<xsd:element name="RequestInterface">
    <xsd:complexType>
        <xsd:sequence>
            <!-- WSI:swaRefのため、swaRef型の引数を用意 -->
            <xsd:element minOccurs="0" name="image" type="ref:swaRef" />
        </xsd:sequence>
    </xsd:complexType>
</xsd:element>

 

Zeepを実行するスクリプト

こちらも SwA のものを流用・修正します。

wsi:swaRefの場合、

  • SOAPエンベロープ部分の image 要素を <ns0:image>cid:image=ham</ns0:image> にする
  • 添付ファイル部分の Content-ID を Content-ID: <image=ham> にする

とするため、

def run(attachment_content_id, is_base64ize=False):
    session = Session()

    # WSI:swaRefの仕様書に合わせ、添付ファイルのContent-IDにプレフィックス 'image=' を追加
    attachment_content_id_with_prefix = f'image={attachment_content_id}'
    transport = WsiSwaRefTransport(ATTACHMENT,
                                   attachment_content_id=attachment_content_id_with_prefix,
                                   is_base64ize=is_base64ize, session=session)

    history_plugin = HistoryPlugin()
    client = Client(str(WSDL), transport=transport, plugins=[history_plugin])

    # WSI:swaRefの仕様書に合わせ、imageタグの値のプレフィックスに 'cid:' を追加
    response = client.service.requestMessage(image=f'cid:{attachment_content_id_with_prefix}')

という修正を加えました。

 

動作確認

SwAと同様、SOAP UIを使って動作を確認します。

SOAP UIをセットアップ後に実行すると、以下の結果となりました。 (量が多いため、バイナリのまま送信したもののみ記載)

$ python wsi_swa_ref_runner.py 
----------------------------------------
添付ファイルはバイナリのまま送信
----------------------------------------
b'--boundary_62604d9834ed41c7bdfbb5a1ea5f7cf7\r\n
Content-Type: text/xml; charset=utf-8\r\n
Content-Transfer-Encoding: 8bit\r\n
Content-ID: start_6b3ff1a1815c429786706f33495e4f25\r\n\r\n

<?xml version=\'1.0\' encoding=\'utf-8\'?>\n
<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
  <soap-env:Body>
    <ns0:RequestInterface xmlns:ns0="http://example.com/HelloWorld">
      <ns0:image>cid:image=ham</ns0:image>
    </ns0:RequestInterface>
  </soap-env:Body>
</soap-env:Envelope>\r\n
--boundary_62604d9834ed41c7bdfbb5a1ea5f7cf7\r\n
Content-Transfer-Encoding: binary\r\n
Content-Type: image/png; name="shinanogold.png"\r\n
Content-ID: <image=ham>\r\n
Content-Disposition: attachment; name="shinanogold.png"; filename="shinanogold.png"\r\n\r\n

\x89PNG...IEND\xaeB`\x82\r\n
--boundary_62604d9834ed41c7bdfbb5a1ea5f7cf7--'
--- history ---
{'envelope': <Element {http://schemas.xmlsoap.org/soap/envelope/}Envelope at 0x111cefec8>, 
 'http_headers': {
   'SOAPAction': '"http://example.com/HelloWorld/requestMessage"', 
   'Content-Type': 'multipart/related; boundary="boundary_62604d9834ed41c7bdfbb5a1ea5f7cf7";
                   type="text/xml"; start="start_6b3ff1a1815c429786706f33495e4f25"; charset=utf-8',
   'Content-Length': '6336'}}
?
--- envelope ---
<soap-env:Envelope xmlns:soap-env="http://schemas.xmlsoap.org/soap/envelope/">
  <soap-env:Body>
    <ns0:RequestInterface xmlns:ns0="http://example.com/HelloWorld">
      <ns0:image>cid:image=ham</ns0:image>
    </ns0:RequestInterface>
  </soap-env:Body>
</soap-env:Envelope>

 
SOAP UIのログを見ると、2エントリが追加されていました。

内容は以下の通りです。

f:id:thinkAmi:20190102155653p:plain:w300

SOAP UIの説明では、wsi:swaRefだとTypeが SWAREF になるようですが、今回は MIME のままでした。
SOAP Attachments and Files | SoapUI

とはいえ、SOAP UI以外の環境がないため、今回はこれで良しとします。

 
また、ファイルをエクスポートしても、送信したファイル shinanogold.png を取得できました。

 

参考

ソースコード

GitHubに上げました。 file_attachments/wsi_swa_ref/ ディレクトリの中が今回のファイルです。
https://github.com/thinkAmi-sandbox/python_zeep-sample