2018年の振り返りと2019年の目標

例年通り、2018年の振り返りと2019年の目標っぽいものを書いてみます。

 

2018年の振り返り

2017年の振り返りと2018年の目標 - メモ的な思考的な で立てた目標を振り返ってみます。

 

Pythonを通じて、いろいろな分野の基礎固め

Blogを振り返ってみると、いろいろな分野に手を出していたようです。

 

その他
DjangoCongress JP 2018での発表

Django/WSGIミドルウェア入門 というタイトルで発表をしました。

 
発表を通じていろいろな発見があったので、とてもためになりました。ありがとうございました。

2019年もCfPを出せるといいな...

 

Blogでのアウトプット

2018年はインプットが多い一方、Blogでのアウトプットが減りました。

「確か昔やったような気がする」という時は、このBlogを検索することが多いです。なので、「将来の自分のために」という点でも良くなかったです。

ただ、そのことに気づいた12月は、小さなアウトプットの継続を心がけられました。

 

GitHub

仕事でも使っているので、プライベートでのcommitのみにしました。こちらもアウトプットが減ってますね...

f:id:thinkAmi:20190101072959p:plain:w300

 

イベント

2018年は、前半を中心にイベントに参加していました。

  1. 「SQLアンチパターン」読書会スペシャルに参加しました #nseg #glnagano #sqlap - メモ的な思考的な
  2. デブサミ2018の二日目に参加しました #devsumi - メモ的な思考的な
  3. #stapy #glnagano みんなのPython勉強会 in 長野#2に参加しました - メモ的な思考的な
  4. Recap of TensorFlow Dev Summit 2018 in 信州 に参加しました & LTしました #tfug #GDG信州 - メモ的な思考的な
  5. 「OSSライセンス」勉強会に参加しました #nseg #glnagano - メモ的な思考的な
  6. 技術書典4に行ってきました #技術書典 - メモ的な思考的な
  7. DjangoCongress JP 2018 に参加 & 発表しました #djangocongress - メモ的な思考的な
  8. #pyconjp PyCon JP 2018に参加しました - メモ的な思考的な

 
また、記録していませんでしたが、ギークラボ長野で開催されている読書会に参加したり、技術書典5にも参加していました。

 

資格まわり

社内Slackのビッグウェーブに乗りました。
Python 3 エンジニア認定基礎試験に合格しました - メモ的な思考的な

あと、

合格のコツは「合格するまで受験する」です。

という言葉をいただいたので、IPA試験の受験を継続しました。

昨年SAの論文で落ちてたため、そのステップとしてPMも受験した結果、PMとSAの両方に合格しました。

 

筋トレ

10月あたりから筋トレを始めて継続しています。

書籍「プリズナートレーニング」を参考に、週2回とても軽い負荷で、ゆるゆるとやっています。

プリズナートレーニング 圧倒的な強さを手に入れる究極の自重筋トレ

プリズナートレーニング 圧倒的な強さを手に入れる究極の自重筋トレ

 

時の流れ

2018年は

  • 地域イベントへの参加が増加
  • 介護の玉突きで余力が減少
  • 親戚の田んぼが、はぜ掛けからコンバインへ

など、時の流れを感じることが多かったです。

 
2019年も時の流れは変わらないため、計画を立てづらい & イベント参加が減りそうな感じなので、残念です。

 

2019年の目標っぽいもの

時の流れもあることから、自分の内部で閉じられる

  • Pythonをベースに、いろいろな分野の素振り
  • 筋トレの継続
  • 何らかのテストを受験
  • 小さなことでも良いので、2018年よりアウトプットを増やす

を目標っぽいものにします。

これ以外にも取り組みますが、目標倒れはつらいので外しておきます(弱気)。

 
というところで、今年もよろしくお願いします。

Python3で、bytes型の文字列にstr型の文字列を埋め込むため、%演算子 + %a を使う

Python3では、文字列表現として

  • str型
  • bytes型

の2つの型があります。

通常はstr型を使うのですが、データ通信などではbytes型を使ったりします。

 
両者は別モノなので、

$ python
Python 3.7.1 (default, Dec 19 2018, 21:58:12) 
[Clang 10.0.0 (clang-1000.11.45.5)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> 'shinano gold' == 'shinano gold'
True
>>> b'shinano gold' == b'shinano gold'
True
>>> 'shinano gold' == b'shinano gold'
False

となります。

 
そんな中、bytes型の文字列にstr型の文字列を埋め込みたいことがあったため、調べたことをメモします。

 
目次

 

環境

 

bytes型では、f-stringsが使えない (PEP-0498より)

Python3.6からはf-stringsを使うことで埋め込みが楽になりましたが、bytes型には使えません。

 
試しに実行してみると、syntaxエラーになりました。

>>> ringo = 'shinano gold'
>>> fb'I love {ringo}'
  File "<stdin>", line 1
    fb'I love {ringo}'
                     ^
SyntaxError: invalid syntax

 

bytes型では .format() が使えない (PEP-0460がobsolete)

PEP-0460がobsoleteのため、bytes型には .format() がありません。
PEP 460 -- Add binary interpolation and formatting | Python.org

 
試しに実行してみると、AttributeErrorになりました。

>>> ringo = 'shinano gold'
>>> b'I love {}'.format(ringo)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
AttributeError: 'bytes' object has no attribute 'format'

 

%演算子で変換型 %a を使う (Python 3.5〜、PEP-0461)

他の方法を探したところ、Python 3.5より % 演算子%a を使うことにより、埋め込めるようになりました。

 
試しに実行してみます。

>>> b'I love %a' % 'shinano gold'
b"I love 'shinano gold'"

' が含まれるものの、埋め込みできました。

 
' が不要な場合は、replace します。

# ワンライナーの場合
>>> (b'I love %a' % 'shinano gold').replace(b"'", b"")
b'I love shinano gold'

# 変数を使う場合
>>> message = b'I love %a' % 'shinano gold'
>>> message.replace(b"'", b"")
b'I love shinano gold'

 
なお、 %r も使えますが、上記の公式ドキュメントページには

'r' は 'a' の別名です。Python 2/3 の両方を対象としたコードでのみ使用すべきです。

と記載されています。

 

bytes型にbytes型を埋め込む時は、 %演算子 + %b を使う

>>> b'I love %b' % b'shinano gold'
b'I love shinano gold'

 
こちらも、 %s が使えますが、

's' は 'b' の別名です。Python 2/3 の両方を対象としたコードでのみ使用すべきです。

とのことです。

GCP Cloud Functions + Python + Slack Outgoing WebHooks App + Zaim APIで、SlackからZaimへ登録する

最近、 Zaim を使って家計簿をつけています。
https://zaim.net/

ただ、時々入力を忘れたり、重複入力してしまうことがありました。

そこで、

  • 日頃Slackを使っている
  • ZaimにはWeb APIがある

ということから、SlackからZaimのデータを登録する仕組みを作りましたので、メモを残します。

なお、今回の範囲では影響ありませんでしたが、Zaimの金融連携データはAPI経由では取得できないようです。プレミアムプラン契約をしている時は取得できるようになってくれるとありがたいです。

 
目次

 

環境

 

作ったもの

長いメモなので、こんな感じのものを作ったというのを書いておきます。

 

SlackからZaimへ登録する

決まったフォーマット (日付(yyyy/mm/dd or mm/dd) ジャンル名 金額 コメント) をSlackにポストすると、Zaimに反映します。

なお、登録する時に西暦を入力するのが手間だったため、西暦が省略された場合は、実行日の西暦を渡すようにしています。

Slackの様子

Zaimに登録できたら、OKなリアクションをします。

f:id:thinkAmi:20181228215056p:plain:w200

Zaimの様子

Zaimにも登録できています。

f:id:thinkAmi:20181228215250p:plain:w300

 
一方、Zaimに登録できない場合は、NGなリアクションと、NGになった理由をスレッドで返信します。

f:id:thinkAmi:20181228215652p:plain:w250

 
他にも、使い方を忘れたときのために、以下の2機能を作りました。

 

Zaimの登録可能なジャンルを知る

ジャンル とポストすると、Zaimで登録可能なジャンルの一覧をスレッドで返信します。

f:id:thinkAmi:20181228215826p:plain:w250

 

Zaimへ登録する際のポストの書式を知る

書式 もしくは フォーマット とポストすると、登録するポストの書式をスレッドで返信します。

f:id:thinkAmi:20181228220003p:plain:w250

 
以降は、これらを作った時のメモになります。

 

事前調査

SlackとZaimをつなぐ方法

SlackとZaimにはAPIがあるので、それらを使えばつなぐことができそうでした。ただ、メンテナンスの手間をかけたくないため、サーバレスで作ろうと考えました。

一般的な構成を調べると、AWS Lambda + API Gateway が多かったです。せっかくなので、今回は別の構成で作ることにしました。

 
GCPで同じ機能がないかを調べたところ、 Cloud Functions がありました。Betaながら、Python3.7.1もサポートしていました。
Google Cloud Functions documentation  |  Cloud Functions  |  Google Cloud

また、 HTTP Functions として作ることで、HTTP(S)のアクセスを直接受け付けるようでした。
HTTP Functions  |  Cloud Functions Documentation  |  Google Cloud

価格表を見ても無料枠があり、ある程度の運用ができそうでした。
Pricing  |  Cloud Functions Documentation  |  Google Cloud

 
そのため、GCP Cloud Functions を使った構成で進めることにしました。

 

Slackのポストをフックする手段

Slackのポストをフックする手段としては以下がありました。

他にも Slash Commands があったものの、Slack上で会話的にやりとりしたかったため、今回は使わないことにしました。
https://api.slack.com/slash-commands

 
まず、 Legacy Outgoing Webhooks を調べたところ、Web上にいろいろな知見がありました。

ただ、Slackのドキュメントには

You're reading this because you're looking for info on legacy custom integrations - an outdated way for teams to integrate with Slack. These integrations lack newer features and they will be deprecated and possibly removed in the future. We do not recommend their use.

https://api.slack.com/custom-integrations/outgoing-webhooks

と書かれていたため、今回は使うのをやめました。

 
次に Events API を見てみました。

その中の message.channels イベントを使えば、Slackへのポストをフックできそうでした。
https://api.slack.com/events/message.channels

しかし、実際に試してみると、全チャンネルの全ポストをフックし、Cloud Functionsにリクエストが来ていました。

Cloud Functionsに無料枠があるとはいえ、全部フックされるのはつらいので、今回は使うのをやめました。

 
最後に Outgoing WebHooks App を見てみました。

調べてみたところ、チャンネル限定でポストをフックできそうでした。

そのため、今回は Outgoing WebHooks App を使ってフックすることにしました。

 

Zaim APIについて

ZaimのAPIドキュメントを見たところ、APIでデータを登録できそうでした *1

また、APIのアクセスレベル(読込/書込/両方)も制御できました。

 

Zaim APIの category と genre について

Zaim APIでは

  • category
  • genre

の2つを指定し、APIを呼ぶ必要がありました。

 
それらが何を指すのか調べたところ、

のようでした。

 
ただ、画面上では両方の名称は確認できるものの、Zaim APIに渡すためのIDが不明でした。

 
Zaim APIの公式ドキュメントを見たところ、 GET /v2/genre を使うことで、categoryとgenreの両IDが取得できそうでした。

 

存在しないジャンルを用いた Zaim APIでの登録について

存在しないジャンルIDを用意して、Zaim APIにて登録してみたところ、エラーになることなく登録できました。

 
そのため、Zaim APIで登録する前に、ポストされたジャンルは正しいかという検証が必要そうでした。

 
以上で、事前調査が終わりました。続いて、実際のアプリケーションを作っていきます。

 

Zaimの設定

新しいアプリケーションの追加

Zaim Developers Centerへアクセスし、新しいアプリケーションを追加します。
Zaim Developers Center

 

アクセストークン系の取得

Zaimにアプリケーションを追加しただけではアクセストークン系が取得できません。

そのため、以下の記事を参考に、アクセストークンを取得するPythonスクリプトを作成します。
requestsを使ったOAuth認証 例題:Flickr - Qiita

なお、今回はターミナルで動かすスクリプトなため、 OAuth1.0aの oauth_callback は、RFC5849に従い oob (out-of-band:帯域外) を指定しました。
https://tools.ietf.org/html/rfc5849#section-2.1

class ZaimClient:
    def __init__(self):
        # 後述しますが、secret.json ファイルに、各種認証情報を設定してある前提
        with pathlib.Path(__file__).parents[1].joinpath('secret.json').open(mode='r') as f:
            secrets = json.load(f)
        self.tokens = secrets['Zaim']

    def print_access_token(self):
        """ Zaimのアクセストークンを取得・表示する

        OAuth1.0aの認証方法については、以下の記事を参考に実装
        https://qiita.com/kosystem/items/7728e57c70fa2fbfe47c
        """
        request_token = self._get_request_token()
        access_token = self._get_access_token(request_token)

        # ターミナル上にアクセストークンを表示する。形式は以下の通り
        # {'oauth_token': 'xxx', 'oauth_token_secret': 'yyy'}
        # oauth_token == ACCESS_TOKEN, oauth_token_secret == ACCESS_TOKEN_SECRET
        print(access_token)

    def _get_request_token(self):
        auth = OAuth1(
            self.tokens['CONSUMER_KEY'],
            self.tokens['CONSUMER_SECRET'],
            # CLIからの認証なので、RFC5849のsection-2.1より、 `oob` を指定しておく
            # https://tools.ietf.org/html/rfc5849#section-2.1
            callback_uri='oob')

        r = requests.post(self.tokens['REQUEST_TOKEN_URL'], auth=auth)
        return dict(urllib.parse.parse_qsl(r.text))

    def _get_access_token(self, request_token):
        # ブラウザを起動してOAuth認証確認画面を表示する
        # ユーザーが許可すると、「認証が完了」のメッセージとともにコードが表示される
        webbrowser.open(
            f'{self.tokens["AUTHORIZE_URL"]}?oauth_token={request_token["oauth_token"]}&perms=delete')

        # ターミナル上で、コードの入力を待つ(コード入力後、後続処理が行われる)
        oauth_verifier = input('コードを入力してください: ')

        auth = OAuth1(
            self.tokens['CONSUMER_KEY'],
            self.tokens['CONSUMER_SECRET'],
            request_token['oauth_token'],
            request_token['oauth_token_secret'],
            verifier=oauth_verifier)
        r = requests.post(self.tokens['ACCESS_TOKEN_URL'], auth=auth)

        access_token = dict(urllib.parse.parse_qsl(r.text))
        return access_token

 
認証情報の入った secrets.json はこんな感じです。各項目はZaimにアプリケーション登録した時に表示される値となります。

{
  "Zaim":
  {
    "REQUEST_TOKEN_URL": "https://your_url",
    "AUTHORIZE_URL": "https://your_url",
    "ACCESS_TOKEN_URL": "https://your_url",
    "CONSUMER_KEY": "xxx",
    "CONSUMER_SECRET": "xxx",
    "ACCESS_TOKEN": "",
    "ACCESS_TOKEN_SECRET": ""
  }
}

 
このスクリプトを実行し、

  • ブラウザが起動するのでログイン
  • 画面にトークンが表示
  • ターミナルにトークンを入力

とすると、ターミナルに以下の形式のアクセストークン系が表示されます。

{'oauth_token': 'xxx', 'oauth_token_secret': 'yyy'}

 
oauth_token (= ACCESS_TOKEN)、oauth_token_secret (= ACCESS_TOKEN_SECRET) を、 secrets.json に反映します。

 
ここまででZaimの設定は完了です。

 

Slackの設定

Slack Appの作成

以下のページより、今回のSlackアプリ (Slackのポストをフックして Cloud Functions を呼び出すアプリ) を作成します。
Slack API: Applications | Slack

 

OAuth Access Token の取得

Slack Appからポストできるようにするため、サイドバーの OAuth & Permissions から OAuth Access Token を取得しておきます。

この値を、前述の secrets.json にも追記しておきます。

 

Scopeの設定

今回、SlackからZaimへ登録した時の結果として、

  • 成功時:Slackポストに絵文字のリアクションを付ける
  • 失敗時:Slackポストに絵文字のリアクションを付けるとともに、ポストのスレッドにエラーメッセージを記載

を行いたいです。

 
そのため、Slack Appに以下の3つの権限を付与しました。

  • channels:history (Access user’s public channels)
  • chat:write:bot (Send messages as )
  • reactions:write (Add or remove emoji reactions for user)

 

Outgoing WebHooks Appの追加

Outgoing WebHooks Appのページから、対象のSlackスペースへアプリをインストールします。
Outgoing WebHooks | Slack App Directory

 

インテグレーションの設定

Outgoing WebHooks Appの設定を追加します。

今回、Slackのポストをフックして

  • Zaimへのデータ登録
  • Zaimへデータ登録する時のジャンルを表示
  • Zaimへデータ登録する時のフォーマットを表示

を実現したいです。

今回は Slash Commands として作成しないことから、

  • 1つの Cloud Functions 関数として作成
  • ポスト内容により、どの機能を動作させるのか振り分ける

ことにしました。

 
そのため、インテグレーションの設定は以下となりました。

項目
チャンネル Zaimへポストするためのチャンネルを専用で用意・指定
引き金となる言葉 空白 (全ての言葉に反応させるため)
URL 現時点では空白 (Cloud Functions側で設定したら、ここも設定)

 
以上がSlackの設定となります。

 

GCP Cloud Functions の設定

Googleアカウントまわりの設定

Cloud Functions を使うためにはクレジットカード情報などを登録する必要があります。

そのため、不正アクセスなどがされにくい & された時に気づきやすい点を考慮し、以下の設定を行いました。

  • 請求先アカウントのチュートリアルから作業する
  • 新しくGmailアカウントを作成
  • 2段階認証を設定
  • GCPでプロジェクトの作成
    • 任意の名前をつける
  • Cloud Functions APIを有効化
  • Cloud Functions APIのページへ移動
  • 無料トライアルに登録
    • 情報入力
      • アカウントの種類:個人
      • 名前と住所:入力
      • お支払い方法:カード情報の入力
  • 予算とアラートを追加
  • Cloud Functions に新しい関数を作成
    • リージョン: asia-northeast1
    • secret.json へ設定した内容を環境変数としても設定
    • トリガータブにエンドポイントURLがあり、これが Slack インテグレーション設定のURLとなる
      • コピペして、Slack の Outgoing WebHooks App の設定へと反映する

 

Cloud Functions の実装で悩んだところ

続いて Cloud Functions の関数を作成しますが、いくつか悩んだところがありました。  

名前について

むやみに外部からアクセスされても困るため、分かりづらい名前を付けることにしました。

そこで、Pythonuuid モジュールを使って、ランダムなuuidを生成し、名前として使うことにしました。
21.20. uuid — UUID objects according to RFC 4122 — Python 3.6.5 ドキュメント

# PythonのREPLを起動
$ python

# uuidモジュールを使って名前を出力
>>> import uuid
>>> print(str(uuid.uuid4()))

 

Slackへのレスポンス3秒ルールへの対応

Outgoing WebHooks Appでは関係ないかもしれませんが、ドキュメントに記載されていないので、念のための考慮となります。

SlackのEvent APIなどでは、Slackからの通知に対して3秒以内にレスポンスする必要があります。

Your app should respond to the event request with an HTTP 2xx within three seconds. If it does not, we'll consider the event delivery attempt failed. After a failure, we'll retry three times, backing off exponentially.

Maintain a response success rate of at least 5% of events per 60 minutes to prevent automatic disabling.

Respond to events with a HTTP 200 OK as soon as you can. Avoid actually processing and reacting to events within the same process. Implement a queue to handle inbound events after they are received.

What you do with events depends on what your application or service does.

https://api.slack.com/events-api#responding_to_events

 
しかし、今回は

  • Outgoing WebHooks Appから通知を受け取る
  • Zaim APIを使って、Zaimへ登録
  • Slack Web APIを使って、Slackへ返信

を行うため、3秒を超過する可能性があります。

 
他のSlackアプリはどうしているのか調べたところ、

  • Slackへは、すぐにHTTP200の返信を行う
  • 実際の処理は、遅延実行する

をしていました。

ただ、ほとんどの記事がAWSで実装されたものであり、Cloud Functionsのものは見当たりませんでした。

 
Cloud Functionsでできる方法を調べたところ、Pythonthreading.Thread が使えそうでした。

 
上記のstackoverflowの回答はHeroku上のものでしたが、Cloud Functionsでも問題なく動作しました。

# background()関数を用意し、そちらで Zaim APIを呼ぶなどの処理を実装する
t = Thread(target=background, kwargs={'request_data': request_data})
t.start()
return ''

 
ただ、本当に使っても大丈夫なのかは公式ドキュメントには見当たらなかったため、自己責任で...

 

Zaimのジャンル情報の保持

Zaim APIでデータを登録する際、ジャンル情報を渡す必要があります。

ただ、渡すジャンル情報はジャンル名ではなく、カテゴリIDとジャンルIDを設定する必要があります。

 
単純に考えると、Zaim APIで登録する前に、Zaim APIでジャンル情報を取得すれば良さそうでした。

ただ、1件登録するのに2回APIを呼ぶのはあまり良くない気がしました。

また、自分の運用を考えたところ、一度カテゴリやジャンルを決めてしまったら、その後は大きく変更していません。

 
そのため、都度 Zaim APIでジャンル情報を取得するのではなく、GCP側でジャンル情報を保持しておくことにしました。

もしジャンル情報を変更した場合は、保持している内容を書き換えるという運用としました。

 
次に保持する場所を考えました。ただ、これだけの目的でDBを使いたくなかったため、環境変数に設定できないかを考えました。

公式ドキュメントで環境変数のサイズを調べてみると、

Size limits

The total number of bytes used by environment variable names and values for an individual function is limited to 32KiB. However, there are no specific limits on individual keys or values within this overall capacity.

https://cloud.google.com/functions/docs/env-var#size_limits

とありました。

自分のジャンルの量からすると、トークンなどを考慮しても、32KiBで収まりそうでした。

 
以上より、環境変数JSON文字列としてジャンル情報をセットしておき、Zaim APIを呼ぶ時にジャンル名からIDへと変換するようにしました。

環境変数に設定する形式は

{"食料品": {"category_id": nnn, "genre_id": nnn}, "カフェ": {"category_id": nnn, "genre_id": nnn}, ... }

な感じとしました。

 

ロギング方法

公式ドキュメントに従い、 logging モジュールを使いました。
Writing, Viewing, and Responding to Logs  |  Cloud Functions Documentation  |  Google Cloud

なお、 logging.debug は動作しませんでした。見た感じだと無視されるようです。

 

Outgoing Webhooks Appからのアクセス判定

Slack Events API や Legacy Outgoing Webhooksでは、 X-Slack-Signature HTTPヘッダを検証することで、Slackからのリクエストかどうかが分かります。
Verifying requests from Slack | Slack

 
ただ、 Types of requests that support signed secrets を見ても、Outgoing Webhooks Appが記載されていません。 https://api.slack.com/docs/verifying-requests-from-slack#types_of_events

 
Outgoing Webhooks Appの設定を見ると、トークンという欄に

このトークンは発信ペイロードに送信されます。Slack チームから来たリクエストを確認する際にそのトークンを使用できます。

と記載されていました。

 
Cloud Functions上でSlackからのリクエストボディを確認すると

# print(request.form)の結果

ImmutableMultiDict([('token', 'xxx'), ('team_id', 'xxx'), ('team_domain', 'xxx'), ...])

でした。

 
そのため、環境変数にOutgoing Webhooks Appのトークンを設定しておくことで、

# 毎回 reqest.formと書くのが手間なので、変数に入れておく (以降の例も同様)
request_data = request.form

if request_data.get('token') != os.environ['SLACK_OUTGOING_WEBHOOKS_TOKEN']:
    # Slack以外のリクエストの処理
    ...

な形で判定できそうでした。

 
なお、Outgoing WebHooks AppからはPOSTしかされない前提のため、今回は request.form を使っています。

クエリストリングとリクエストボディの両方を取得したい場合は、 request.values になります。
http://werkzeug.pocoo.org/docs/0.14/wrappers/#werkzeug.wrappers.BaseRequest.values

 

Slack Web APIからの投稿かどうかの判定

今回、登録エラーとなった場合は、Slackのスレッドでエラーメッセージをポストします。

しかし、何も制御しないと、

  • Slack Web APIを使って、エラーメッセージを投稿
  • エラーメッセージの投稿がOutgoing WebHooks Appでフックされ、Cloud Functionsに送信
  • 同じくエラーが出るため、エラーメッセージを投稿
  • (以降繰り返し)

と、無限ループする可能性があります。

 
Events APIを使ってフックする場合は、 sub_type を見ることで Slack Web APIでの投稿かどうかが分かりそうです。

 
しかし、Outgoing WebHooks Appを使った場合、渡されてくるのは

  • token
  • team_id
  • team_domain
  • channel_id
  • channel_name
  • timestamp
  • user_id
  • user_name
  • text
  • trigger_word

だけでした。

 
そこで、自作のSlack App にはBotを作成しない状態で Slack Web APIchat.postMessage を使ってみたところ、 user_name に slackbot という値が渡されてきました。

 
そのため、Slack Web APIの投稿かどうかは、

if request_data.get('user_name') == 'slackbot':
    logging.info(f'bot access data:{request_data.get("text")}')
    return ''

と判定することにしました。

 

空データのアクセス判定

以上を実装した後にログを眺めていたところ、Outgoing WebHooks Appでは、本来のアプリからのアクセスの他に、1,2回アクセスが発生していることが分かりました。

また、この場合、リクエストデータが空っぽでした。

 
そのため、リクエストデータが空っぽの場合は、不明なアクセスと考えて、処理しないようにしました。

if request_data.get('token') != os.environ['SLACK_OUTGOING_WEBHOOKS_TOKEN']:
    logging.warning(f'not slack access, data: {request_data}')
    return ''

 

Cloud Functionsに登録した関数の内容

今までの内容をもとに作成した関数は以下の通りです。

""" GCP Cloud Functions を使って、SlackからZaimへデータをPostするためのスクリプト """

import json
import logging
import os
import unicodedata
from datetime import datetime
from threading import Thread

import zaim
from slackclient import SlackClient


def background(request_data):
    """ Cloud Functionsの別スレッドで動作する関数 """

    # 登録可能なジャンルを知りたい場合
    has_genre_response = response_all_genre(request_data)
    if has_genre_response:
        return

    # Zaimへ登録するためのフォーマットを知りたい場合
    has_format_response = response_format(request_data)
    if has_format_response:
        return

    error_msg, zaim_data = create_zaim_data(request_data)

    if zaim_data:
        error_msg = post_zaim(zaim_data)

    client = SlackClient(os.environ['SLACK_TOKEN'])
    if error_msg:
        # 念のため、ログにもエラーメッセージを出力しておく
        logging.debug(error_msg)

        # エラーの場合、NGリアクションとスレッドにエラーメッセージをポスト
        client.api_call(
            'reactions.add',
            name='man-gesturing-no',
            channel=request_data['channel_id'],
            timestamp=request_data['timestamp'],
        )
        client.api_call(
            'chat.postMessage',
            channel=request_data['channel_id'],
            thread_ts=request_data['timestamp'],
            text=error_msg,
        )
    else:
        client.api_call(
            'reactions.add',
            name='man-gesturing-ok',
            channel=request_data['channel_id'],
            timestamp=request_data['timestamp'],
        )


def response_all_genre(request_data):
    """ ジャンルを知りたい場合は、環境変数にあるジャンル一覧をスレッドとして返信する """
    text = request_data.get('text')
    if text != 'ジャンル':
        return False

    genre = load_genre()
    all_genre = ', '.join(genre.keys())

    client = SlackClient(os.environ['SLACK_TOKEN'])

    client.api_call(
        'reactions.add',
        name='book',
        channel=request_data['channel_id'],
        timestamp=request_data['timestamp'],
    )

    client.api_call(
        'chat.postMessage',
        channel=request_data['channel_id'],
        thread_ts=request_data['timestamp'],
        text=all_genre,
    )
    return True


def response_format(request_data):
    """ Zaimへ投稿するフォーマットを知りたい場合は、環境変数にあるジャンル一覧をスレッドとして返信する """
    text = request_data.get('text')
    if text not in ('書式', 'フォーマット'):
        return False

    text = '日付(yyyy/mm/dd or mm/dd) ジャンル名 金額 コメント ' \
           '(4項目は順不同、区切りはスペース(全角/半角どちらでも可))'

    client = SlackClient(os.environ['SLACK_TOKEN'])

    client.api_call(
        'reactions.add',
        name='memo',
        channel=request_data['channel_id'],
        timestamp=request_data['timestamp'],
    )

    client.api_call(
        'chat.postMessage',
        channel=request_data['channel_id'],
        thread_ts=request_data['timestamp'],
        text=text,
    )
    return True


def create_zaim_data(request_data):
    """ Zaimデータを作成する

    :param request_data: リクエストされたデータ
    :return: エラーメッセージ, Zaimデータ
    :rtype: str, dict
    """
    text = request_data.get('text')
    if not text:
        return '登録データがありません', None

    zaim_data = parse_zaim_data(text)
    if len(zaim_data.keys()) != 5:
        return f'登録するための項目が不足しています :{zaim_data}', None

    return None, zaim_data


def parse_zaim_data(text):
    """ SlackのポストをZaimデータにparseする

    :param text: Slackのポスト
    :return: Zaimデータ
    :rtype: dict
    """

    # Slackポストのフォーマット
    # 項目:日付、ジャンル名、金額、コメント (順不同、区切りはスペース(全角/半角どちらでも可))
    # 各項目や区切り文字は全角/半角のどちらでも可とするため、内部では正規化して処理する
    text_normalized = unicodedata.normalize('NFKC', text)

    # 入力項目ごとに区切る
    words = text_normalized.split()

    results = {}
    genre = load_genre()
    for word in words:
        if '/' in word:
            results['date'] = get_date(word)
        elif word.isdigit():
            results['amount'] = int(word)
        elif word in genre:
            category_id, genre_id = get_ids(word)
            if category_id and genre_id:
                results['category_id'], results['genre_id'] = category_id, genre_id
        else:
            today = datetime.today()
            results['comment'] = f'{word} (Slack登録: {today.year}/{today.month}/{today.day})'

    return results


def get_ids(word):
    """ カテゴリID、ジャンルIDを取得する

    :param word: ジャンルっぽい文字列
    :return: カテゴリID, ジャンルID (存在しない場合: None, None)
    :rtype: str, str
    """
    genres = load_genre()
    genre = genres.get(word)
    if genre:
        return genre['category_id'], genre['genre_id']
    return None, None


def load_genre():
    """ 環境変数からジャンルを取得し、Pythonオブジェクト化する """
    genre = os.environ.get('ZAIM_GENRE')
    if not genre:
        return

    return json.loads(genre)


def get_date(date_text):
    """ スラッシュ区切りで日付を指定

    01/01 -> 同年の1/1
    1/1 -> 同上
    2018/1/1 -> 年数も考慮
    それ以外 -> 判断つかないので、本日とする
    """
    date_list = date_text.split('/')
    if len(date_list) == 2:  # 月日のみ
        str_date = f'{datetime.today().year}{date_list[0].zfill(2)}{date_list[1].zfill(2)}'
        return datetime.strptime(str_date, '%Y%m%d')

    if len(date_list) == 3:  # 年月日
        str_date = f'{date_list[0]}{date_list[1].zfill(2)}{date_list[2].zfill(2)}'
        return datetime.strptime(str_date, '%Y%m%d')

    return datetime.today()


def post_zaim(zaim_data):
    """ Zaim APIにポストする

    :param zaim_data: Zaimにポストするためのデータ
    :return: 正常の場合はNone、エラーがある場合はエラーメッセージ
    :rtype: None or str
    """
    try:
        api = zaim.Api(consumer_key=os.environ['CONSUMER_KEY'],
                       consumer_secret=os.environ['CONSUMER_SECRET'],
                       access_token=os.environ['OAUTH_TOKEN'],
                       access_token_secret=os.environ['OAUTH_TOKEN_SECRET'])
        api.verify()

        # api.payment()の戻り値は以下の通り。そのため、戻り値を使って何かする、ということは無い
        # 正常:レスポンスのJSON内容が返ってくる
        # エラー:例外が出る
        api.payment(
            # 存在しないcategory_idでPOSTすると、「振替」だけれど変なデータができてしまうが、OKで通る
            # ただし、数字のところに文字列を入れると、例外が発生する
            # category_id="101xx",
            category_id=zaim_data['category_id'],
            genre_id=zaim_data['genre_id'],
            amount=zaim_data['amount'],
            date=zaim_data['date'],
            comment=zaim_data['comment']
        )
        return None
    except Exception as e:
        return str(e)


def main(request):
    """ Cloud Functions 呼ばれるメインの関数 """
    # Outgoing WebHooks App からは、POSTしかデータ送信されない前提なので、.formを使う
    # クエリストリングも同時に取得したい場合は .values を使う
    # 毎回 reqest.formと書くのが手間なので、変数に入れておく
    request_data = request.form

    # Outgoing Webhooks アプリだと、本来のアプリの他に、1,2回アクセスがある
    # この場合、request.formは空になっている
    if not request_data:
        logging.info(f'empty form data:{request_data}')
        return ''

    # Outgoing WebHooks アプリからの送信かどうかのバリデーション
    if request_data.get('token') != os.environ['SLACK_OUTGOING_WEBHOOKS_TOKEN']:
        logging.warning(f'not slack access, data: {request_data}')
        return ''

    # Botの場合に返信してしまうと、無限ループになるため除外する
    if request_data.get('user_name') == 'slackbot':
        logging.info(f'bot access data:{request_data.get("text")}')
        return ''

    # Slackの3秒ルールがあるため、リクエストが届いたということを通知するために
    # メイン処理は別スレッドに流して、ここは HTTP 200 をすぐに返す
    t = Thread(target=background, kwargs={'request_data': request_data})
    t.start()
    return ''

 
あとはこれを稼働させることで、冒頭のSlackやZaimのスクリーンショットの内容が実現できました。

 

ソースコード

GitHubに上げました。
https://github.com/thinkAmi/slack2zaim

*1:ZaimにログインしないとAPIドキュメントが読めないので、URLを貼るのはやめておきます

Python 3.6.4で、pip install python-memcached==1.58 したところ「tuple index out of range」エラー

python-memcachedの環境構築をする時に

# 仮想環境を作る
$ python -m venv env364

# アクティベート
$ source env364/bin/activate

# Pythonのバージョンを確認
(env364) $ python --version
Python 3.6.4

# pipでインストール
(env364) $ pip install python-memcached==1.58
Collecting python-memcached==1.58
  Using cached https://files.pythonhosted.org/packages/f7/62/14b2448cfb04427366f24104c9da97cf8ea380d7258a3233f066a951a8d8/python-memcached-1.58.tar.gz
    Complete output from command python setup.py egg_info:
    Traceback (most recent call last):
      File "<string>", line 1, in <module>
      File "/private/var/folders/h0/l5plp4zd3517r988jpm481g00000gn/T/pip-build-q026ekfn/python-memcached/setup.py", line 8, in <module>
        version=get_module_constant('memcache', '__version__'),
      File "/path/to/lib/python3.6/site-packages/setuptools/depends.py", line 164, in get_module_constant
        return extract_constant(code, symbol, default)
      File "/path/to/lib/python3.6/site-packages/setuptools/depends.py", line 195, in extract_constant
        const = code.co_consts[arg]
    IndexError: tuple index out of range

とエラーが出てインストールができなかったため、対応した時のメモです。

 

環境

  • Python 3.6.4
  • setuptools 28.8.0

 

対応

python-memcachedGitHubにissueがありました。
can't install python-memcached1.58 in Python 3.6.0b2 · Issue #103 · linsomniac/python-memcached

setuptoolsのバージョンが古いために発生しているようでした。

 
そのため、setuptoolsのバージョンを上げてからインストールしたところ成功しました。

# setuptoolsのバージョンを上げる
(env364) $ pip install -U setuptools
Collecting setuptools
  Using cached https://files.pythonhosted.org/packages/37/06/754589caf971b0d2d48f151c2586f62902d93dc908e2fd9b9b9f6aa3c9dd/setuptools-40.6.3-py2.py3-none-any.whl
Installing collected packages: setuptools
  Found existing installation: setuptools 28.8.0
    Uninstalling setuptools-28.8.0:
      Successfully uninstalled setuptools-28.8.0
Successfully installed setuptools-40.6.3

# python-memcachedをインストール
(env364) $ pip install python-memcached==1.58
Collecting python-memcached==1.58
  Using cached https://files.pythonhosted.org/packages/f7/62/14b2448cfb04427366f24104c9da97cf8ea380d7258a3233f066a951a8d8/python-memcached-1.58.tar.gz
Requirement already satisfied: six>=1.4.0 in ./env364/lib/python3.6/site-packages (from python-memcached==1.58)
Installing collected packages: python-memcached
  Running setup.py install for python-memcached ... done
Successfully installed python-memcached-1.58

 
ちなみに、python-memcached 1.59 ではエラーが発生せずにインストールできるようです。

# 仮想環境を作る
$ python -m venv env364_1

# アクティベート
$ source env364_1/bin/activate

# setuptoolsのバージョンを確認
(env364_1) $ pip list
pip (9.0.1)
setuptools (28.8.0)

# インストール
(env364_1) $ pip install python-memcached
Collecting python-memcached
  Using cached https://files.pythonhosted.org/packages/f5/90/19d3908048f70c120ec66a39e61b92c253e834e6e895cd104ce5e46cbe53/python_memcached-1.59-py2.py3-none-any.whl
Collecting six>=1.4.0 (from python-memcached)
  Using cached https://files.pythonhosted.org/packages/73/fb/00a976f728d0d1fecfe898238ce23f502a721c0ac0ecfedb80e0d88c64e9/six-1.12.0-py2.py3-none-any.whl
Installing collected packages: six, python-memcached
Successfully installed python-memcached-1.59 six-1.12.0

Python + msoffcrypto-toolで、Excelの読み取りパスワードを解除する

前回、Python + openpyxlで、ブックやシートの保護・解除を試しました。
Python + openpyxlで、ブックやシートの保護・解除を試してみた - メモ的な思考的な

 
その際、openpyxlではExcelの読み取りパスワードを解除できませんでした。

ただ、読み取りパスワード設定済のExcelファイルを読み込みたいことがあったため、別のライブラリがないかを調べてみました。

なお、今回もパスワードは把握している前提です。パスワードのクラックではありません。

 
目次

 

環境

 

調査したライブラリ

xlwings

 
Web上に、xlwings を使って読み取りパスワードを解除する事例がありました。
Load password protected Excel files into Pandas DataFrame - David Hamann

 
ただ、 wb = xw.Book(PATH) にてExcelファイルを開く際に、読み取りパスワード入力ダイアログが表示されました。

f:id:thinkAmi:20181215113543p:plain:w300

 
手動でパスワード入力するのが手間なため、他のライブラリを探すことにしました。

 

pywin32

 
読み取りパスワードを解除するよう

excel = win32com.client.Dispatch('Excel.Application')
book = excel.Workbooks.Open('対象のファイル', False, False, None, '読み取りパスワード')
book.SaveAs('解除後のファイル', None, '')
book.Close()

と実装したところ、読み取りパスワード入力ダイアログが表示されずに解除できました。

しかし、 pywin32Windows上でしか動作せず、Macでは利用できないため、他のライブラリを探すことにしました。

 
ちなみに、最近の pywin32 は、 pypiwin32 としてPyPIからインストールできるようです。
https://pypi.org/project/pypiwin32/

名前などが怪しいですが、メンテナーが pywin32 とほぼ同じなので、大丈夫な気がします。
https://pypi.org/project/pywin32/

 

msoffcrypto-tool

 
GitHubのREADMEに従い、Macのターミナルから実行したところ、読み取りパスワード入力ダイアログが表示されずにパスワードが解除されました。

 
README上ではライブラリとして使う方法も記載されていたため、 msoffcrypto-tool を使うことにしました。

 

msoffcrypto-toolの実装

実装の流れは以下です。

# 対象のExcelを開く
with file.open(mode='rb') as locked:

    # OfficeFileオブジェクトにする
    office_file = msoffcrypto.OfficeFile(locked)

    # パスワードを設定する
    office_file.load_key(password=PASSWORD)

    # 解除する(ファイルは上書き保存される)
    office_file.decrypt(unlocked)

これにより、Excelファイル(xlsx, xlsの両方)とも、読み取りパスワードを解除できました。

 
ただ、読み取りパスワードが設定されていないExcelファイルに対して実行すると、例外が発生しました。

xlsxxls では、例外が発生する箇所が異なりました。

  • xlsx
    • office_file = msoffcrypto.OfficeFile(locked) のタイミング
  • xls
    • office_file.load_key(password=PASSWORD) のタイミング

 
なお、msoffcrypto.OfficeFileには、読み取りパスワードが設定されているかをチェックするメソッド is_encrypted() があります。

ただし、 xlsx 形式では常に True が返ってくる実装になっていることに注意が必要です。
https://github.com/nolze/msoffcrypto-tool/blob/v4.6.3/msoffcrypto/format/ooxml.py#L143

 
以上より、読み取りパスワードが設定されていないファイルでも動作するように修正してみました。

import msoffcrypto
import pathlib


BASE_DIR = pathlib.Path(__file__).resolve().parents[0]
PASSWORD = '12345'
UNLOCKED_FILE = BASE_DIR.joinpath('unlocked.xlsx')


def unlock():
    for file in BASE_DIR.iterdir():
        # Excelファイルだけ対象
        if not file.is_file() or file.suffix not in ['.xlsx', '.xls']:
            continue

        with file.open(mode='rb') as locked:
            # xlsxファイルの場合、読み取りパスワード無しのファイルは例外が発生する
            # is_encrypted()には以下の記載がある
            #
            # https://github.com/nolze/msoffcrypto-tool/blob/v4.6.3/msoffcrypto/format/ooxml.py#L143
            # def is_encrypted(self):
            #     # olefile cannot process non password protected ooxml files.
            #     # Hence if it has reached here it must be password protected.
            #     return True
            try:
                office_file = msoffcrypto.OfficeFile(locked)
            except OSError:
                if file.suffix == '.xlsx':
                    continue
                raise

            # 読み取りパスワードが設定されているかをチェック(xlsxはチェックできないので、xls向け)
            if not office_file.is_encrypted():
                continue

            # パスワードをセット
            # xlsでパスワードが設定されていない場合、load_key()時にエラーが出るため、事前にチェックが必要
            #   File "python3.6/site-packages/msoffcrypto/format/xls97.py", line 479, in load_key
            #     # Skip to FilePass; TODO: Raise exception if not encrypted
            #     num, size = workbook.skip_to(recordNameNum['FilePass'])
            #   File "python3.6/site-packages/msoffcrypto/format/xls97.py", line 428, in skip_to
            #     raise Exception("Record not found")
            # Exception: Record not found
            office_file.load_key(password=PASSWORD)

            # 読み取りパスワード解除後のファイルは、拡張子の前に '_unlocked' を付けて保存する
            unlocked_file = BASE_DIR.joinpath(f'{file.stem}_unlocked{file.suffix}')
            with unlocked_file.open(mode='wb') as unlocked:
                # パスワードを解除
                office_file.decrypt(unlocked)


if __name__ == '__main__':
    unlock()

 

ソースコード

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

Python + openpyxlで、ブックやシートの保護・解除を試してみた

Excelには、ブックやシートを保護するための機能があります。

  • 読み取りパスワード
  • 書き込みパスワード
  • ブックの保護
  • シートの保護

 
それらをopnepyxlでやるにはどうしたら良いかを試した時のメモです。

なお、パスワードは把握している前提です。パスワードのクラックではありません。

また、公式ドキュメントだとこのあたりのことです。
Protection — openpyxl 2.5.12 documentation

 
目次

 

環境

 

読み取りパスワード・書き込みパスワードの設定は不可

openpyxlでは読み取りパスワードと書き込みパスワードは設定できないようです。

issueによると

because it is proprietary MS code not covered by the OOXML specification.

https://bitbucket.org/openpyxl/openpyxl/issues/193/can-i-open-password-protected-excel-files

とのことです。

 

ブックの保護

パスワード無しでブックの保護を行う

Workbookオブジェクトには security 属性があり、それに対して保護の設定を行うことで実現できます。

ブックの保護なしのファイルを読み込むと、 security 属性は None です。

そのため、 openpyxl.workbook.protection.WorkbookProtection オブジェクトを設定した上で、 lockStructure = True とします。

# ブックを読み込み
wb = _load(NO_PROTECTION_FILE)

# ブックを保護
wb.security = WorkbookProtection()
wb.security.lockStructure = True

# 保存
_save(wb, f'No_1_PROTECT_book_using_{NO_PROTECTION_FILE}')


# _load()と_save()は以下の関数(以降のソースコードも同様)
# ディレクトリ名を付けると一行が長くなって見づらかったので、関数化した
def _load(file_name):
    return openpyxl.load_workbook(BASE_FILE_DIR.joinpath(file_name))

def _save(workbook, file_name):
    workbook.save(RESULT_FILE_DIR.joinpath(file_name))

 
結果です。

f:id:thinkAmi:20181215100903p:plain:w300

 

パスワードありでブックを保護する

パスワード無しに加え、 wb.security.workbook_password = PASSWORD_FOR_BOOK を追加します。

wb = _load(NO_PROTECTION_FILE)

# ブックを保護
wb.security = WorkbookProtection()
wb.security.lockStructure = True
wb.security.workbook_password = PASSWORD_FOR_BOOK

# 保存
_save(wb, f'No_2_PROTECT_book_using_{NO_PROTECTION_FILE}')

 
結果です。ブックの保護を解除しようとすると、パスワード入力が求められます。

f:id:thinkAmi:20181215101129p:plain:w300

 

シートの保護

パスワード無しでシートを保護する

シートオブジェクトの protection 属性にある enable() メソッドを使います。

なお、公式ドキュメントではブックオブジェクトに対して設定するよう記載されていましたが、シートオブジェクトが正しそうです。
https://openpyxl.readthedocs.io/en/stable/protection.html#worksheet-protection

wb = _load(NO_PROTECTION_FILE)

# 対象のワークシートオブジェクトを取得する
ws = wb['Sheet1']

# パスワード無しで保護
ws.protection.enable()

# 保存
_save(wb, f'No_3_PROTECT_sheet_without_password_{NO_PROTECTION_FILE}')

 
結果です。

f:id:thinkAmi:20181215101422p:plain:w300

 

パスワードありでシートを保護する

パスワードなしに加え、 ws.protection.password = PASSWORD_FOR_SHEET を使います。

# 対象のワークシートオブジェクトを取得する
ws = wb['Sheet1']

# パスワードをセット
ws.protection.password = PASSWORD_FOR_SHEET

# シートを保護
ws.protection.enable()

 

シートの保護時に「このシートのすべてのユーザーに許可する操作」を設定する

Excelでは、シートの保護時に このシートのすべてのユーザーに許可する操作 というチェックボックスがいくつかあります。

openpyxlでそれらを有効化する場合は以下となります。

# 対象のワークシートオブジェクトを取得する
ws = wb['Sheet1']

# このシートのすべてのユーザーに許可する操作
ws.protection.objects = True                # オブジェクトの編集
ws.protection.scenarios = True              # シナリオの編集
ws.protection.formatCells = True            # セルの書式設定
ws.protection.formatColumns = True          # 列の書式設定
ws.protection.formatRows = True             # 行の書式設定
ws.protection.insertColumns = True          # 列の挿入
ws.protection.insertRows = True             # 行の挿入
ws.protection.insertHyperlinks = True       # ハイパーリンクの挿入
ws.protection.deleteColumns = True          # 列の削除
ws.protection.deleteRows = True             # 行の削除
ws.protection.selectLockedCells = True      # ロックされたセルの選択
ws.protection.selectUnlockedCells = True    # ロックされていないセルの選択
ws.protection.sort = True                   # 並べ替え
ws.protection.autoFilter = True             # フィルター
ws.protection.pivotTables = True            # ピボットテーブルレポート

# パスワード無しで保護
ws.protection.enable()

 

セルをロックしないで、シートを保護する

Excelのデフォルトでは、シートを保護すると全セルにロックがかかり、セルへの入力ができなくなります。

一部のセルのみ入力可能にしてシートを保護したい場合、事前にセルのロックを解除します。

openpyxlでは、以下のようにします。

from openpyxl.styles import Protection

wb = _load(NO_PROTECTION_FILE)
ws = wb['Sheet1']

# ロックを外したい(保護されない)セルを選ぶ
unlock_cells = ws['A1:B3']

# 取得したデータや型を見ると、行ごとにタプルでセルが入っている
print(f'type: ({type(unlock_cells)}), values: {unlock_cells}')
# => type: (<class 'tuple'>), values: ((<Cell 'Sheet1'.A1>, <Cell 'Sheet1'.B1>),
#                                      (<Cell 'Sheet1'.A2>, <Cell 'Sheet1'.B2>),
#                                      (<Cell 'Sheet1'.A3>, <Cell 'Sheet1'.B3>))

# chain.from_iterable()でネストタプルを平坦にしてから処理 (使ってみたかっただけ)
# 普通は for の2重ループで良いのかな
for cell in chain.from_iterable(unlock_cells):

    # 念のための確認
    print(f'type: ({type(cell)}), values: {cell}')
    # => type: (<class 'openpyxl.cell.cell.Cell'>), values: <Cell 'Sheet1'.A1>

    # ロックを解除
    cell.protection = Protection(locked=False)

# シートを保護
ws.protection.enable()

 
結果です。

ロックされたセルの場合、入力しようとすると、以下のようにメッセージが表示されます。

f:id:thinkAmi:20181215102139p:plain:w300

一方、ロックされていないセルの場合、入力が可能です。

f:id:thinkAmi:20181215102154p:plain:w300

 

ブックの保護を解除

ブックの保護時とは逆で、 wb.security.lockStructure = False とします。

また、パスワード付きブックの保護の場合は、 wb.security.workbook_password = PASSWORD_FOR_BOOK を設定します。

wb = _load(BOOK_PROTECTION_FILE)

# 保護したときのパスワードをセット
wb.security.workbook_password = PASSWORD_FOR_BOOK

# ブックの保護を解除
# wb.security.lock_structureでも良い:Aliasが設定されている
wb.security.lockStructure = False

_save(wb, f'No_6_UNPROTECT_{BOOK_PROTECTION_FILE}')

 
結果です。ブックの保護が解除されています。

f:id:thinkAmi:20181215102630p:plain:w300

 

シートの保護を解除

シートの保護とは別のメソッド ws.protection.disable() を使います。

また、パスワードで保護している場合は、 ws.protection.password = PASSWORD_FOR_SHEET で設定する必要があります。

wb = _load(SHEET_PROTECTION_WITH_PASSWORD_FILE)
ws = wb['Sheet1']

# シートを保護したときのパスワードをセット
ws.protection.password = PASSWORD_FOR_SHEET

# シートの保護を解除
ws.protection.disable()

_save(wb, f'No_8_UNPROTECT_{SHEET_PROTECTION_WITH_PASSWORD_FILE}')

 
結果です。指定したシート Sheet1 のみシートの保護が解除されています。

f:id:thinkAmi:20181215103043p:plain:w300

 

ソースコード

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

Python + openpyxlで、 0:00 という値を持つセルが正しく保存されない

JSL (日本システム技研) Advent Calendar 2018 - Qiita の5日目の記事です。

 
Excel (*.xlsx) ファイルをPythonで作成するため、openpyxlを使いました。
https://bitbucket.org/openpyxl/openpyxl/src

 
そんな中、 0:00 という値を持つセルが正しく保存されなかったため、原因調査と対応を行ったときのメモです。

 
目次

 

環境

 

現象

Excel上で A1 セルに 0:00 、A2セルに 0:01 を入力したシートを用意します。

f:id:thinkAmi:20181205212715p:plain:w450

 
表示形式は以下の通りです。

f:id:thinkAmi:20181205212735p:plain:w450

 
このようなファイルに対し、openpyxlで開いて別名で保存をします。

BEFORE = "zero_time.xlsx"
AFTER = "zero_time_after.xlsx"

wb = openpyxl.load_workbook(BEFORE)
wb.save(AFTER)

 
別名で保存したファイルを開くと、 0:00 を設定したセルが変更されてしまっています。

f:id:thinkAmi:20181205212913p:plain

 

原因

openpyxlのissueを見ると、似たような事例がありました。
openpyxl / openpyxl / issues / #1043 - Time '0:00' is not saved correctly after loading and saving a file — Bitbucket

いわゆる1900年うるう日問題が原因のようです。

 
どこかでこのあたりを見たなと思ったら、過去に書いていました。
C#とAccessにおける、日付型(DateTime)のデフォルト値の差について - メモ的な思考的な

 
では、実際に値を見てみます。

def print_before_and_after(before, after):
    print('--- before ---')
    print_a1_a2(before)

    print('--- after ---')
    print_a1_a2(after)


def print_a1_a2(path):
    wb = openpyxl.load_workbook(path)
    sheet = wb.active
    a1_value = sheet['A1'].value
    a2_value = sheet['A2'].value
    print(f'A1: {a1_value}({type(a1_value)}), A2: {a2_value}({type(a2_value)})')
    wb.close()


print_before_and_after(BEFORE, AFTER)
# =>
# --- before ---
# A1: 1899-12-30 00:00:00(<class 'datetime.datetime'>), A2: 00:01:00(<class 'datetime.time'>)
# --- after ---
# A1: 1899-12-29 00:00:00(<class 'datetime.datetime'>), A2: 00:01:00(<class 'datetime.time'>)

 
気になる点は、

  • 1899/12/30から1899/12/29へと値が変更されていた
  • 0:00は datetime.datetime 型、0:01は datetime.date 型と、違いがある

です。

Excel上でも -1 と数式欄に表示されていたのも、この影響のように見えます。

 

対応

【NG】iso_dates=True を設定

まずは、issueに記載されていたように、 workbook.iso_dates=True を設定してみます。

wb = openpyxl.load_workbook(BEFORE)
wb.iso_dates = True
wb.save(ISO)

print_before_and_after(BEFORE, ISO)
# =>
# --- before ---
# A1: 1899-12-30 00:00:00(<class 'datetime.datetime'>), A2: 00:01:00(<class 'datetime.time'>)
# --- after ---
# A1: 1899-12-30 00:00:00(<class 'datetime.datetime'>), A2: 00:01:00(<class 'datetime.time'>)

データ上は良さそうです。

ただ、実際のファイルを開いてみたところ、 #VALUE となっていました。

f:id:thinkAmi:20181205214224p:plain:w450

 

Excelのゼロ日付 (1899/12/30) の時だけ特別処理

0:00 の時は日付が -1 されてしまうため、ゼロ日付の時は日付を1加算するロジックを追加してみました。

FIXED = "zero_time_fix.xlsx"
EXCEL_ZERO_DATE = datetime(1899, 12, 30, 0, 0, 0)

wb = openpyxl.load_workbook(BEFORE)
wb = fix_default_date(wb, "A1")
wb = fix_default_date(wb, "A2")
wb.save(FIXED)


def fix_default_date(workbook, cell):
    sheet = workbook.active
    value = sheet[cell].value
    if value == EXCEL_DEFAULT_DATE:
        sheet[cell].value = EXCEL_ZERO_DATE + timedelta(days=1)
    return workbook

 
データを確認してみましたが、大丈夫そうです。

print_before_and_after(BEFORE, FIXED)
# =>
# --- before ---
# A1: 1899-12-30 00:00:00(<class 'datetime.datetime'>), A2: 00:01:00(<class 'datetime.time'>)
# --- after ---
# A1: 1899-12-30 00:00:00(<class 'datetime.datetime'>), A2: 00:01:00(<class 'datetime.time'>)

 
表示上も良さそうでした。

f:id:thinkAmi:20181205214746p:plain

 

ソースコード

GitHubに上げました。 openpyxl/zero_time ディレクトリが今回のものです。
https://github.com/thinkAmi-sandbox/python_excel_libraries-sample