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

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

以前、SOAP with Attachments (SwA) にて、SOAPでファイルを送信してみました。
Python + Zeep にて、SOAP with Attachment (SwA) でファイルを送信する - メモ的な思考的な

 
今回は、SwAに似た swaRefという仕様でファイルを送信してみます。
仕様書:SOAP Messages with Attachments

 
なお、今回扱う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 href="cid:spam"/>
    </ns0:RequestInterface>
  </soap-env:Body>
</soap-env:Envelope>

のように <ns0:image href="cid:spam"/> と、 href 属性を持っているため、WSI:swaRefとは異なります*1

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

 
目次

 

環境

 

実装するもの

です。

Transportについては、SwAの実装を流用できますので、今回は省略します。

後述のGitHubにはTransportも実装してありますので、そちらを参照してください。

 

WSDLの実装

今回は image elementに href 属性を追加します。

そのため、

  • 名前空間の追加
  • ref属性を使って、要素に href 属性を追加

をします。

<!-- myという名前空間を追加 -->
<wsdl:definitions
...
        xmlns:my="http://example.com/HelloWorld"
        targetNamespace="http://example.com/HelloWorld">

    <wsdl:types>
        <xsd:schema elementFormDefault="qualified" targetNamespace="http://example.com/HelloWorld">
            <xsd:element name="RequestInterface">
                <xsd:complexType>
                    <xsd:sequence>
                        <!-- swaRef用の引数を用意:実体は別のところで定義 -->
                        <xsd:element ref="my:image"/>
                    </xsd:sequence>
                </xsd:complexType>
            </xsd:element>

            <!-- href属性を持つimage elementを用意 -->
            <xsd:element name="image">
                <xsd:complexType>
                    <xsd:attribute name="href" type="xsd:string" />
                </xsd:complexType>
            </xsd:element>

 

Zeepを実行するスクリプト

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

変更は1点で、

response = client.service.requestMessage(image={'href': f'cid:{attachment_content_id}'})

と、imageタグの href 属性に値を設定するようにしています。

 

動作確認

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

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

$ python swa_ref_runner.py 
----------------------------------------
添付ファイルはバイナリのまま送信
----------------------------------------
b'--boundary_1a4cde05b2ab465a8b838ea3e15614d3\r\n
Content-Type: text/xml; charset=utf-8\r\n
Content-Transfer-Encoding: 8bit\r\n
Content-ID: start_237f3846a0214dc4ac767e5722f31eaa\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 href="cid:ham"/>
    </ns0:RequestInterface>
  </soap-env:Body>
</soap-env:Envelope>\r\n
--boundary_1a4cde05b2ab465a8b838ea3e15614d3\r\n
Content-Transfer-Encoding: binary\r\n
Content-Type: image/png; name="shinanogold.png"\r\n
Content-ID: <ham>\r\n
Content-Disposition: attachment; name="shinanogold.png"; filename="shinanogold.png"\r\n\r\n

\x89PNG\r\n\...\x00\x00IEND\xaeB`\x82\r\n
--boundary_1a4cde05b2ab465a8b838ea3e15614d3--'
--- history ---
{'envelope': <Element {http://schemas.xmlsoap.org/soap/envelope/}Envelope at 0x10ad2cc88>, 
 'http_headers': {
   'SOAPAction': '"http://example.com/HelloWorld/requestMessage"', 
   'Content-Type': 'multipart/related; boundary="boundary_1a4cde05b2ab465a8b838ea3e15614d3"; 
                   type="text/xml"; start="start_237f3846a0214dc4ac767e5722f31eaa"; charset=utf-8',
   'Content-Length': '6321'}}
?
--- 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 href="cid:ham"/>
    </ns0:RequestInterface>
  </soap-env:Body>
</soap-env:Envelope>

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

内容は以下の通りです。

f:id:thinkAmi:20190102151032p:plain:w300

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

 

参考

 

ソースコード

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

*1:SwA系についてはいろいろな種類があり、正直良く分かってないので、どこかに一覧でまとまっているサイト/本があれば、教えていただけるとありがたいです

WSDLのelementにattributeを追加し、Python + ZeepでSOAPのエンベロープを作成する

swaRefの仕様書を眺めていたところ、

<?xml version='1.0' ?>
<SOAP-ENV:Envelope
        xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/">
    <SOAP-ENV:Body>
        <claim:insurance_claim_auto id="insurance_claim_document_id"
            xmlns:claim="http://schemas.risky-stuff.com/Auto-Claim">
            <theSignedForm href="cid:claim061400a.tiff@claiming-it.com"/>
            <theCrashPhoto href="cid:claim061400a.jpeg@claiming-it.com"/>
            <!-- ... more claim details go here... -->
        </claim:insurance_claim_auto>
    </SOAP-ENV:Body>
</SOAP-ENV:Envelope>

となっていました。

気になったのは

<theSignedForm href="cid:claim061400a.tiff@claiming-it.com"/>

のように、 element theSignedForm に、attribute href があったことです。

 
elementにattributeのあるWSDLを書いたことがないため、今回

  • WSDLで element に attribute を追加する方法
  • Zeepで、 attributeに値を設定する方法

をそれぞれ調べてみました。

 
目次

 

環境

 

WSDLのelementにattributeを追加する方法

WSDLXMLなので、XMLでの追加方法を調べてみました。

属性を追加する場合は、xsd:attribute要素を使用します。

複数要素を定義する4つの基本形を覚えよう:SEのためのXML Schema入門(2) - @IT

とのことなので、WSDL内の型定義を

<xsd:element name="RequestInterface">
    <xsd:complexType>
        <xsd:sequence>
            <xsd:element minOccurs="0" name="image"/>
        </xsd:sequence>

        <!-- 追加 -->
        <xsd:attribute name="href" type="xsd:string" />
    </xsd:complexType>
</xsd:element>

として、 image elementに href を追加できないか試してみました。

 
結果は、

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

と、その親 RequestInterface elementに付きました。

 
RequestInterfaceの子の image element に追加できないかを調べたところ、同じページに

ref属性を記述した場合、要素の構造については別の場所で宣言します。そして、ref属性の値に記されている要素を参照します。

とありました。

 
そのため、WSDL

<!-- 名前空間 my (任意の名前で可)を追加 -->
<wsdl:definitions
...
        xmlns:my="http://example.com/HelloWorld"
        targetNamespace="http://example.com/HelloWorld">

    <wsdl:types>
        <!-- ここのtargetNamespaceも適当に設定(先ほどのと同じでもOK) -->
        <xsd:schema elementFormDefault="qualified" targetNamespace="http://example.com/HelloWorld">
            <xsd:element name="RequestInterface">
                <xsd:complexType>
                    <xsd:sequence>
                        <!-- このelementにattributeを設定するため、refで別の型を参照させる -->
                        <!-- SOAP UIで動作させるため、名前空間(my)を付与して、参照先を明確にする -->
                        <xsd:element minOccurs="0" ref="my:image"/>
                    </xsd:sequence>
                </xsd:complexType>
            </xsd:element>

            <!-- attributeを付けたい型の定義-->
            <xsd:element name="image">
                <xsd:complexType>
                    <xsd:attribute name="href" type="xsd:string" />
                </xsd:complexType>
            </xsd:element>

として試してみます。

 
なお、WSDL中のコメントにも記載しましたが、 ref 属性を使うときは、

  • wsdl:definitions に、my という名前空間の定義を追加
  • ref="my:image" のように名前空間付きで定義

を行います。名前空間がない場合、SOAP UIでWSDLをimportする際、エラーとなってしまいます。

参考:xml - What does the ref attribute on an element in an XSD do? - Stack Overflow

 
実行結果は

<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 href="foo_bar"/>
    </ns0:RequestInterface>
  </soap-env:Body>
</soap-env:Envelope>

と、image elementに href attribute が追加されました。

 
ちなみに、

<xsd:element name="RequestInterface">
    <xsd:complexType>
        <xsd:sequence>
            <xsd:element minOccurs="0" name="image"/>

            <!-- sequenceの中にattributeを追加 -->
            <xsd:attribute name="href" type="xsd:string" />
        </xsd:sequence>
    </xsd:complexType>
</xsd:element>

と、 <xsd:sequence> の中に <xsd:attribute> を入れると、以下のエラーになります。

zeep.exceptions.XMLParseError: Unexpected element {http://www.w3.org/2001/XMLSchema}attribute in xsd:sequence

 

WSDLのattributeに、Zeepから値を設定する

WSDLでelementにattributeを付けられたものの、どうすればZeepから値を与えられるのかが分かりませんでした。

調べてみたところ、Stack Overflowに回答があったため、それを参考に実装してみます。
Python Zeep - how to set attributes for element - Stack Overflow

 

親要素のattributeを設定する

client.service.requestMessage(href='ham_spam') と、attribute名の引数に対し、設定したい値を渡します。

history_plugin = HistoryPlugin()
child_wsdl = BASE_PATH.joinpath('root_attribute.wsdl')
client = Client(str(child_wsdl), plugins=[history_plugin])

# Zeepと同様、requests_mockを使って、POSTをMockする
# https://github.com/mvantellingen/python-zeep/blob/3.2.0/tests/integration/test_http_post.py#L15
with requests_mock.mock() as m:
    m.post('http://localhost:9500/attributeBindingSoap11', text='<root>mocked!</root>')

    # requestMessage()の結果がWSDLの内容と異なるため、常にXMLSyntaxErrorが出る
    # 今回は送信したSOAPエンベロープの値を見たいので、例外は無視する
    try:
        response = client.service.requestMessage(href='ham_spam')
    except XMLSyntaxError:
        pass

    print(etree.tostring(history_plugin.last_sent['envelope'],
                         pretty_print=True, encoding='unicode'))

 
実行すると、親要素の RequestInterfacehref に値が設定されました。

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

 

子要素のattributeを設定する

親要素との変更点は、 client.service.requestMessage(image={'href': 'foo_bar'}) と、element名の引数に対し、属性名のdictを渡すことだけです。

history_plugin = HistoryPlugin()
child_wsdl = BASE_PATH.joinpath('child_attribute.wsdl')
client = Client(str(child_wsdl), plugins=[history_plugin])

with requests_mock.mock() as m:
    m.post('http://localhost:9501/attributeBindingSoap11', text='<root>mocked!</root>')
    try:

        # image elementの要素をdictで渡す
        response = client.service.requestMessage(image={'href': 'foo_bar'})

    except XMLSyntaxError:
        pass

    print(etree.tostring(history_plugin.last_sent['envelope'],
                         pretty_print=True, encoding='unicode'))

 
実行すると、子要素の imagehref に値が設定されました。

<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 href="foo_bar"/>
    </ns0:RequestInterface>
  </soap-env:Body>
</soap-env:Envelope>

 

ソースコード

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