Python: Larkが1.0.0になったので試してみたんだけど挙動がおかしい

年が明けたら Lark の 1.0.0 がリリースされていたのに気が付いたので、以前作ったコマンド形式の入力ファイルをパースするのを、再度やってみた。フレームワーク的なパッケージにまとめて再利用できるようにしてみようと思う……んだけど、どうも挙動がおかしい。

要点を先に書くと、書いたパーサも入力ファイルも同じなのに、実行するたびに結果が異なる。原因はわからない。パーサの書き方が悪いのか、Lark のバグなのか。

順を追って説明する。

コマンド形式の入力ファイル

プログラムには何らかの入力が必要で、入力ファイルにも何らかのフォーマットが必要だ。コマンド形式というのは、入力データの内部表現を、ひとつずつコマンドを実行しながら組み立てようというものだ。この記事で扱うのは、そのフレームワーク的なライブラリで、ファイルからの入力である文字列を、コマンド(と引数)の列にパースするもの。

サンプルとして次のような入力ファイルを使う。

TITLE "example"
ADD 1 2
MUL
    3
//    5
    7  8  
    9
// this line is comment.
REPEAT "Hello!" 5  
    // this is comment too.
JOIN "Hello," "world!"  // in-line comment.
ADD-2  5  .7e03
NO-ARG-COMMAND
COMMAND-WITH-KEYWORD
    KW1  1
    KW2  2
    KW3  on
    KW4  off

文法

全体を「スクリプト」と呼ぶ。文法は次の通り。

  • 「スクリプト」は1つ以上の「ステートメント」からなる
  • 「ステートメント」は「コマンド」と0個以上の「引数」からなり、空白文字で区切られ、改行文字で終わる
  • ただし、空白文字で始まる行は前の行の続きとみなす。なので「コマンド」は必ず行頭からはじまる
  • 「引数」には数値、文字列、キーワード、真偽値の4種類がある
  • //から行末まではコメント

「引数」について補足しておく。数値は文字通りの数値だけど、整数/実数の区別はなし。文字列はダブルークォートで囲む。真偽値は true / falseyes / noon / off のいずれか(すべて小文字)。

で、キーワードが今日の主題。「引数」のリストの中で、次の引数が何のデータかを示す目印に使う。要するに Python のメソッドのキーワード引数のような使い方だ。大文字のラテン文字と数字からなり、行頭には来ないことでコマンドと区別できる。実装としては Ruby なら Symbol をつかうところだけど、Python にはそういうのがないので(ないよね?)、Keyword クラスを定義した。

文法ファイルは次のようになった。

?script : statement+

statement : line continued*

line : command _WS_INLINE arglist _WS_INLINE? _NL
     | command _WS_INLINE? _NL

continued : _INDENT arglist _WS_INLINE? _NL

command : CMDNAME

CMDNAME : UCASE_LETTER ("-"|UCASE_LETTER|DIGIT)*

arglist : arg
        | arglist _WS_INLINE arg

arg : number
    | string
    | keyword
    | boolean

number : SIGNED_NUMBER

string : ESCAPED_STRING

keyword : KWORD

KWORD : UCASE_LETTER (UCASE_LETTER|DIGIT)*

boolean : true
        | false

true : "true" | "yes" | "on"

false : "false" | "no" | "off"

_WS_INLINE : WS_INLINE

_NL : NEWLINE

_INDENT : WS_INLINE


%import common.UCASE_LETTER
%import common.DIGIT
%import common.SIGNED_NUMBER
%import common.ESCAPED_STRING
%import common.NEWLINE
%import common.WS_INLINE
%import common.CPP_COMMENT

COMMENT : WS_INLINE? CPP_COMMENT NEWLINE
COMMENT_INLINE : WS_INLINE? CPP_COMMENT

%ignore COMMENT
%ignore COMMENT_INLINE

パーサ

from lark import Lark
from lark.exceptions import UnexpectedInput
from lark.visitors import Interpreter


class Parser():
    def __init__(self):
        with open('grammer.lark', 'r') as f:
            grammer = f.read()
        self.parser = Lark(grammer, start='script')

    def parse(self, input_data):
        try:
            tree = self.parser.parse(input_data)
        except UnexpectedInput as e:
            context = e.get_context(input_data)
            print(f'Syntax error:  line = {e.line}  column = {e.column}\n')
            print(context)
            exit(1)

        script = ScriptInterpreter().visit(tree)
        return script


class ScriptInterpreter(Interpreter):
    def script(self, tree):
        return [self.visit(c) for c  in tree.children]

    def statement(self, tree):
        (cmd, args1) = self.visit(tree.children[0])
        args2 = flatten([ self.visit(a) for a in tree.children[1:] ])
        return (cmd, args1 + args2)

    def line(self, tree):
        cmd = self.visit(tree.children[0])
        if len(tree.children) > 1:
            args = flatten(self.visit(tree.children[1]))
        else:
            args = []
        return (cmd, args)

    def continued(self, tree):
        return self.visit(tree.children[0])

    def command(self, tree):
        return tree.children[0]

    def arglist(self, tree):
        args = [ self.visit(a) for a in tree.children ]
        return args

    def arg(self, tree):
        return self.visit(tree.children[0])

    def number(self, tree):
        return float(tree.children[0])

    def string(self, tree):
        return tree.children[0].strip('"')

    def keyword(self, tree):
        return Keyword(str(tree.children[0]))

    def boolean(self, tree):
        return self.visit(tree.children[0])

    def true(self, tree):
        return True

    def false(self, tree):
        return False


class Keyword():
    def __init__(self, val):
        self.val = val

    def __str__(self):
        return f'Keyword<{self.val}>'

    def __repr__(self):
        return f'Keyword<{self.val}>'


def flatten(lis):
    result = []
    for elem in lis:
        if isinstance(elem, list):
            result += flatten(elem)
        else:
            result.append(elem)
    return result

テスト用のスクリプトと実行結果

テスト用なので、入力データ(の内部表現)を組み立てる代わりにコマンドと引数リストを出力する。

from parsers import Parser
import sys


def main():
    parser = Parser()

    with open(sys.argv[1], 'r') as f:
        input_data = f.read()

    script = parser.parse(input_data)

    print('SCRIPT')
    for (cmd, args) in script:
        print('  COMMAND: ' + cmd)
        print('     ARGS: ' + repr(args))



main()

これを実行すると次のようになる。

takatoh@sofa: inputscriptparser-sample > python main.py example.dat
SCRIPT
  COMMAND: TITLE
     ARGS: ['example']
  COMMAND: ADD
     ARGS: [1.0, 2.0]
  COMMAND: MUL
     ARGS: [3.0, 7.0, 8.0, 9.0]
  COMMAND: REPEAT
     ARGS: ['Hello!', 5.0]
  COMMAND: JOIN
     ARGS: ['Hello,', 'world!']
  COMMAND: ADD-2
     ARGS: [5.0, 700.0]
  COMMAND: NO-ARG-COMMAND
     ARGS: []
  COMMAND: COMMAND-WITH-KEYWORD
     ARGS: [Keyword<KW1>, 1.0, Keyword<KW2>, 2.0, Keyword<KW3>, True, Keyword<KW4>, False]

これは期待通り。ところが、何度か実行を続けると、時々次のような結果になる。

takatoh@sofa: inputscriptparser-sample > python main.py example.dat
SCRIPT
  COMMAND: TITLE
     ARGS: ['example']
  COMMAND: ADD
     ARGS: [1.0, 2.0]
  COMMAND: MUL
     ARGS: [3.0, 7.0, 8.0, 9.0]
  COMMAND: REPEAT
     ARGS: ['Hello!', 5.0, Keyword<JOIN>, 'Hello,', 'world!']
  COMMAND: ADD-2
     ARGS: [5.0, 700.0]
  COMMAND: NO-ARG-COMMAND
     ARGS: []
  COMMAND: COMMAND-WITH-KEYWORD
     ARGS: [Keyword<KW1>, 1.0, Keyword<KW2>, 2.0, Keyword<KW3>, True, Keyword<KW4>, False]

コマンドであるべき JOIN が、REPEAT コマンドの引数リストの中に、JOIN というキーワードとして含まれてしまっている(JOIN に続く引数もろとも)。

最初に書いたとおり、文法ファイルも入力ファイルも、パーサも何も変えてない。なのに実行するたびに、JOIN だけコマンドになったりキーワードになったりする。

今のところ全くの原因不明。

気になるところといえば、入力ファイルの JOIN コマンドの前の行が空白文字とコメントであること。さらにもう一つ前の行の行末(5 のうしろ)に空白文字があること(この記事では見えないけど)だ。だけどこれは文法上は問題ないはずに思える。実際期待通りの結果になることもあるんだし。

というわけで、このままでは安心して使えない。どうしようか。

AWS CLIでAmazon S3のバケットにファイルをアップロードする

AWS CLI は AWS の各サービスをコマンドラインから使えるツールだ。Amazon S3 をデータのバックアップ用に使おうと思って試してみた。

インストールと設定

AWS の Web ページからダウンロードする。↓のページから。

ダウンロードした awscliv2.zip を解凍する。

takatoh@apostrophe:~$ unzip awscliv2.zip

できた aws ディレクトリにあるインストールスクリプトを実行。

takatoh@apostrophe:~$ sudo aws/install
You can now run: /usr/local/bin/aws --version

メッセージにあるとおりコマンド名は aws だ。バージョンを確認してみよう。

takatoh@apostrophe:~$ aws --version
aws-cli/2.4.4 Python/3.8.8 Linux/5.4.0-91-generic exe/x86_64.ubuntu.20 prompt/off

続いて設定。aws configure コマンドを実行。

takatoh@apostrophe:~$ aws configure
AWS Access Key ID [None]: XXXXXXXXXXXXXXXXXXXX
AWS Secret Access Key [None]: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
Default region name [None]: us-west-2
Default output format [None]: json

AWS Access Key ID と AWS Secret Access Key はあらかじめ取得しておくこと。

S3にファイルをアップロード

aws s3 cp コマンドを利用する。今回はテスト用の panicblanket-test というバケットにファイルをアップロードする。

takatoh@apostrophe:~$ aws s3 cp sample.zip s3://panicblanket-test/sample.zip

ディレクトリごとアップロードするには aws s3 sync コマンドが便利。バックアップ用途にはこちらがいいだろう。

takatoh@apostrophe:~$ aws s3 sync sample s3://panicblanket-test/sample
upload: sample/sample-1.zip to s3://panicblanket-test/sample/sample-1.zip
upload: sample/sample-2.zip to s3://panicblanket-test/sample/sample-2.zip

sample ディレクトリにあった2つのファイルがアップロードされた。

あとはシェルスクリプトを書いて定期的に事項するようにしておけば良さそうだ。

SQLite3のデータベースファイルからSQLにダンプする

たいしたネタじゃないけど、ときどき必要になる割にいつも調べてる気がするのでメモしておく。

SQLite3 のデータベースは1つのファイルになってる。このデータベースの中身を SQL にダンプするには sqlite3 コマンドをつかって、次の2通りのやり方がある。

対話的インターフェイスでダンプする方法

データベースを開くと、コマンド入力待ち状態になる。

takatoh@wplj:db$ sqlite3 production.sqlite3
SQLite version 3.11.0 2016-02-15 17:29:24
Enter ".help" for usage hints.
sqlite>

ここで .dump コマンドでダンプすることができるけど、このままだと画面に出力されてしまうので、先に .output コマンドで出力先をファイルに変更しておく。

sqlite> .output ./dump.sql

これでカレントディレクトリの dump.sql ファイルに出力されるようになる。そしたら .dump コマンド。

sqlite> .dump

引数なしだとデータベース内のすべてのテーブルをダンプする。特定のテーブルだけをダンプするには、そのテーブル名を引数に与えればいい。

終わったら .exit

sqlite> .exit

コマンドラインから直接ダンプする方法

パイプを使って sqlite3 コマンドにダンプを指示する .dump コマンドを流し込んでやればいい。デフォルトでは標準出力に書き出すので、ファイルにリダイレクトする。

takatoh@wplj:db$ echo '.dump' | sqlite3 production.sqlite3 > dump2.sql

定期的にバックアップするような場合にはこっちのほうが便利。

MediaWikiにYouTubeの動画を埋め込む

ローカルネットワーク上で使ってる MediaWiki に YouTube の動画を埋め込みたいと思って調べたら、そのまんま、YouTube という名前の拡張機能があった。マニュアルに使い方が載ってる。

これを使うことにする。

環境

  • Ubuntu server 20.04 LTS
  • Docker 20.10.8
  • docker-compose 1.25.5
  • MediaWiki 1.35.0

MediaWiki は Docker コンテナ上で動いていて、これは dockerhub から取ってきた公式のイメージを利用してる。

拡張機能のダウンロードとインストール

拡張機能は、上のリンクからたどってダウンロードした。YouTube-REL1_36-c352d8c.tar.gz っていうファイル名だった。

マニュアルによると、これを MediaWiki のディレクトリにある extensions/ ディレクトリに置けばいい。コンテナの中に入って確認すると、(コンテナ内の)/var/www/html/extensions/ に置けばいいことがわかった。

とはいえ、コンテナ内に置く必要はなくて、外(つまりホスト側)に置いてボリュームをマウントすればいい。実際、LocalSettings.php ファイルなんかもそうなっていて、~/docker-environments/wiki/ 以下に置いてある。

というわけで、ここに extensions/ ディレクトリを作って、YouTube 拡張機能を配置する。

$ cd ~/docker-environments/wiki
$ mkdir extensions
$ cd extensions
$ tar xzvf ~/YouTube-REL1_36-c352d8c.tar.gz

これで extensions/ ディレクトリの下に YouTube/ ディレクトリができる。

つぎに、MediaWiki の設定ファイル LocalSettings.php に1行書き加える。

wfLoadExtension('YouTube');

YouTube っていう拡張機能を読み込む設定だ。MediaWiki についてはこれで終わり。

Dockerコンテナの設定と再起動

拡張機能自体はコンテナの外に置いてあるので、ボリュームとしてマウントする。docker-compose.yml ファイルの volumes セクションに書き加える。

      - /home/takatoh/docker-environments/wiki/extensions/YouTube:/var/www/html/extensions/YouTube

最後にコンテナを起動し直して終わり。

$ docker-compose restart wiki

確認

実際に MediaWiki のページに YouTube の動画を埋め込めることを確認した。楽なもんだね。

Dockerコンテナ上のMariaDBにホスト側から接続する

MariaDB には mysql コマンドで接続できるはずだけど、Docker コンテナに乗ってるときには追加のオプションが必要だったので、メモしておく。

docker-compose.yml はこんなの。

version: "3"

services:

  db:
    image: mariadb:10.5.6-focal
    container_name: test-db
    restart: always
    ports:
      - 8802:3306
    volumes:
      - ./mysql:/var/lib/mysql
      - ./initdb.d:/docker-entrypoint-initdb.d
    environment:
      - MYSQL_ROOT_PASSWORD=root
      - MYSQL_DATABASE=test
      - MYSQL_USER=test
      - MYSQL_PASSWORD=test
      - BIND-ADDRESS="0.0.0.0"
    tty: true

コンテナを起動する。

takatoh@apostrophe:test$ docker-compose up -d

で、ポート 8802 に繋いであるんだからそれを指定してやればいいんだろうと、次のようにやったらダメだった。ポートを指定してるのにソケットがないと怒られる。

takatoh@apostrophe:test$ mysql -u root -p -P 8802
Enter password: 
ERROR 2002 (HY000): Can't connect to local MySQL server through socket '/var/run/mysqld/mysqld.sock' (2)

解決策は簡単に見つかった。次のようにすればいい。

takatoh@apostrophe:test$ mysql -u root -p -h localhost -P 8802 --protocol tcp

--protocol オプションで tcp を使うってことを明示する。-h オプションはなくても大丈夫だった。多分ほかの PC から接続するときには必要なんだろう。

Docker上のMediaWikiにファイルをアップロードする

公式の Docker イメージを使って立てた MediaWiki だけど、ファイルをアップロードするにはやっぱりひと手間必要だった。なので、そのメモ。

MediaWiki にはファイルをアップロードする機能があるけど、デフォルトでは無効になっている。有効にするには MediaWiki と PHP 自体の設定ファイルを修正する必要がある。

  • LocalSettings.php – MediaWiki の設定ファイル
  • php.ini – PHP 自体の設定ファイル

どこをどう修正すればいいかはマニュアルに書いてある。

LocalSettings.php

LocalSettings.php ファイルは、MediaWiki をセットアップしたときにホストにダウンロードして、Docker コンテナには volume としてマウントしてあるので、ホスト側のファイルを編集すればいい。

$wgEnableUploads = true;

$wgEnableUploads に true を設定。

php.ini

で、問題はこっち。php.ini ファイルは MediaWiki の公式 Docker イメージに含まれてるものをそのまま使ったので、ホスト側にはない。なので、まずはコンテナの中で編集して、動作が変わるかどうか確認することにした。

takatoh@wplj $ docker exec -it wiki bash
root@bd8268684983:/var/www/html# php -i | grep php.ini
Configuration File (php.ini) Path => /usr/local/etc/php

php.ini ファイルの場所は php -i コマンドで確認できる(と MediaWiki のマニュアルに書いてある)。このコマンドの出力は結構な量を吐くので grep で php.ini にヒットする行だけ抜き出した。ともあれ、/usr/local/etc/php にあることがわかった。

ところが、そこに php.ini ファイルはなかった。

root@bd8268684983:/var/www/html# cd /usr/local/etc/php
root@bd8268684983:/usr/local/etc/php# ls
conf.d  php.ini-development  php.ini-production

そういうものなのかと疑問に思いながらも、ひとまずは php.ini-production ファイルの中身を見てみようとしても less コマンドがない。

root@bd8268684983:/usr/local/etc/php# less php.ini-production
bash: less: command not found

当然のように vim もない。cat はあったので中身は見れたけど編集はできない。

といわけで、方針を変更してファイルをホスト側にコピーして編集し、LocalSettings.php ファイルと同様にコンテナにマウントすることにした。ファイルをホスト側にコピーするのは次の通り:

takatoh@wplj $ docker exec -it wiki cat /usr/local/etc/php/php.ini-production > php.ini

cat コマンドをコンテナ側で実行して、ホスト側の php.ini ファイルへリダイレクトしている。

で、その php.ini ファイルを編集。3行続けて載せたけど、ファイル中では別々のところにある。

file_uploads = On
post_max_size = 16M
upload_max_filesize = 8M

file_uploads は元から On になってた。post_max_size と upload_max_filesize はそれぞれ 8M2M だったのを大きくした。

docker-compose.yml

編集した php.ini ファイルを LocalSettings.php ファイルと同じディレクトリに配置したら、コンテナにマウントすべく docker-compose.yml を修正。

  wiki:
    image: mediawiki:1.35.0
    container_name: wiki
    restart: always
    depends_on:
      - mysql
    volumes:
      - /home/takatoh/var/wiki/images:/var/www/html/images
      - /home/takatoh/var/wiki/LocalSettings.php:/var/www/html/LocalSettings.php
      - /home/takatoh/var/wiki/php.ini:/usr/local/etc/php/php.ini
    ports:
      - 9090:80

これでOK。

最後にコンテナを起動しなおしたら、無事、ファイルをアップロードできるようになった。

aptあるいはDockerの怪

apt コマンドのせいなのか Docker のせいなのかわからないけど、とにかく不可解な現象に遭遇したので記録しておく。

TL;DR

  • 先月から自宅のサーバで動かしている web サービスを少しずつ Docker 上に移行する作業をしている。
  • 開発用のマシン(Ubuntu 20.04 LTS)で期待通り動作する設定(Dockerfile と docker-compose.yml)ができたのでサーバ(Ubuntu 18.04 LTS)に持っていったら Docker イメージのビルドでコケた。
  • Dockerfile の中の apt update を apt-get update に変えたら通った。

開発用のマシンにて

開発用の環境は次の通り:

  • Ubuntu 20.04.1 LTS
  • Docker version 19.03.8
  • docker-compose version 1.25.0

で、期待通り動作するように書き上げた Dockerfile はこう:

FROM ubuntu:20.04

LABEL maintainer="takatoh"

RUN apt update
RUN apt install -y \
    ruby \
    ruby-dev \
    gcc \
    g++ \
    make \
&& rm -rf /var/lib/apt/lists/*

ENV GEM_HOME /usr/local/bundle
ENV PATH $GEM_HOME/bin:$GEM_HOME/gems/bin:$PATH
RUN gem install bundler unicorn

ADD ./files/lcstorage-2.1.0.tar.gz /usr/
WORKDIR /usr/lcstorage
RUN bundle install

CMD [ "unicorn", "-c", "/var/lcstorage/unicorn.conf" ]

Ruby で書いた web アプリを unicorn で動かしている。この Dockerfile でイメージをビルドして、ちゃんと期待通りに動作するのを確認した。

サーバにて

サーバの環境は次の通り:

  • Ubuntu 18.04.5 LTS
  • Docker version 19.03.6
  • docker-compose version 1.17.1

開発用の環境よりもバージョンが旧いといえばそのとおりだけど、OS はともかく Docker や docker-compose はそんなに旧いわけではない。実際、別の web アプリを同じようにサーバで動かしていて、それをビルドしたときには何の問題もなかった。

ところが、今回この Dockerfile をもとにサーバでイメージをビルドすると途中でエラーが発生した。どうも apt コマンドでパッケージをインストールしている途中でコケるようだ。

仕方がないので手順をひとつずつ手動でやってみることにした。ubuntu:20.04 のイメージからコンテナを起動して、apt update し、パッケージを順にひとつずつ apt install した。が、なんの問題もなくすべてのパッケージのインストールができてしまった。

どういうこと?

Dockerfile を使ってビルドしたときにはパッケージのインストールのところでエラーが出てたんだから、手動でひとつずつインストールすればどのパッケージのインストールでエラーが出るのか判断できる、と考えてた。けど、手動でやったらエラーが出ずに終わってしまった。これじゃ手がかりがない。

唯一の手がかりは Dockerfile でビルドしたときのエラーログだ。次のように出ていた。

E: Failed to fetch http://security.ubuntu.com/ubuntu/pool/main/l/linux/linux-libc-dev_5.4.0-56.62_amd64.deb  404  Not Found [IP: 91.189.88.152 80]
 E: Unable to fetch some archives, maybe run apt-get update or try with --fix-missing?

http://security.ubuntu.com/ubuntu/pool/main/l/linux/linux-libc-dev_5.4.0-56.62_amd64.deb が見つからない、と言ってるけど、ブラウザで見てみると確かにこの URL が示すファイルがない。

でも、じゃあなんで開発用のマシンでは問題なかったんだ?

いろいろググってはみたものの、有力な手掛かりは見つからなかった。

と、もういちどエラーメッセージを見ると、apt-get update を実行しろみたいなことが書いてある。apt じゃなくて apt-get だ。apt コマンドは apt-get コマンドの置き換えなんだからそんなの関係あるか?と思いながら、他に手掛かりがないので Dockerfile を修正してみた。こうだ:

- RUN apt update
+ RUN apt-get update

すると、どういうわけかエラーなくビルドできてしまった。

はぁ?

結論

何が起きたのかよくわからん。いや、さっぱりわかんない。

でも、とにかく動くようになったのでひとまずは良しとする。が、やっぱり釈然としない。原因を追求してみたいけど、手に負えるかなぁ……

docker-composeでMediaWikiを動かす

ローカルネットワークで wiki を運用してるんだけど、Docker 上に移行すべく、今日はそのテスト。

環境

  • Ubuntu 20.04
  • Docker 19.03.8
  • docker-compose 1.25.0

Dockerイメージ

MediaWiki も MariaDB も Docker Hub に公式イメージが有るのでそれを使わせてもらう。データベースは、はじめは MySQL を試したんだけどうまく動かなかった(原因不明)ので MariaDB に変えた。

  • mediawiki:1.35.0
  • mariadb:10.5.6-focal

ディレクトリ構成とファイル

用意した構成はこんなの:

takatoh@apostrophe:testwiki$ tree .
.
├── docker-compose.yml
├── mysql
│   └── db
└── wiki
    └── images

docker-compose.yml はこう:

version: '3'

services:
  testwiki:
    container_name: testwiki
    image: mediawiki:1.35.0
    restart: always
    ports:
      - 8888:80
    volumes:
      - ./wiki/images:/var/www/html/images
#      - ./wiki/LocalSettings.php:/var/www/html/LocalSettings.php

  mysql:
    container_name: db
    image: mariadb:10.5.6-focal
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: rootpasswd
      MYSQL_DATABASE: testwiki
      MYSQL_USER: mysqluser
      MYSQL_PASSWORD: mypassword
    ports:
      - 3306:3306
    volumes:
      - ./mysql/db:/var/lib/mysql

コメントアウトしてある行は MediaWiki の設定ファイル。これはインストールが済んでから使う。

コンテナの起動とMediaWikiのインストール

docker-compose コマンドでコンテナを起動する。

takatoh@apostrophe:testwiki$ docker-compose up -d

ブラウザで http://localhost:8888/ にアクセスして MediaWiki のインストール(というかセットアップというか)をする。その際、データベース関係は docker-compose.yml の記述に合わせる。

  • データベースのホスト:db
  • データベース名:testwiki
  • インストールで使用する利用者アカウント→データベースのユーザ名:mysqluser
  • インストールで使用する利用者アカウント→データベースのパスワード:mypassword

その他はそれなりに設定すればいい。最後に LocalSettings.php ファイル(設定ファイル)をダウンロードしてインストールは終わり。

設定の反映

いったんコンテナを止める。

takatoh@apostrophe:testwiki$ docker-compose down

ダウンロードした設定ファイルを配置。

takatoh@apostrophe:testwiki$ cp ~/Downloads/LocalSettings.php wiki

docker-compose.yml のコメントをはずす(該当行だけ示す)。

      - ./wiki/LocalSettings.php:/var/www/html/LocalSettings.php

コンテナを再起動。

takatoh@apostrophe:testwiki$ docker-compose up -d

これで無事起動した。

参考にしたページ

[追記:2020/11/5] データベースの移行

旧い wiki からデータを移行する手順。

  • データは wiki.sql ファイルにダンプしてあるものとする
  • 旧いほうの MediaWiki のバージョンは 1.27.1

wiki.sql ファイルを Docker コンテナと共有しているディレクトリにコピーする。

takatoh@apostrophe:testwiki$ cp wiki.sql ./mysql/db

./mysql/db ディレクトリは、データベースの Docker コンテナ(コンテナ名は db)からは /var/lib/mysql として認識されている(前述の docker-compose.yml ファイルを参照)。なのでデータベースのコンテナに接続して、データを流し込む。

takatoh@apostrophe:testwiki$ docker exec -it db bash
root@007d71dfcb37:/# cd /var/lib/mysql
root@007d71dfcb37:/var/lib/mysql# ls *.sql
wiki.sql
root@007d71dfcb37:/var/lib/mysql# mysql -u mysqluser -p testwiki < wiki.sql
Enter password:
root@007d71dfcb37:/var/lib/mysql# exit
exit

これでデータベース側での作業は終了。ただ、このままだと MediaWiki でエラーになる。バージョンが上がっているので MediaWiki の使用するデータベーススキーマとかも変わっているからだ。

そこで、MediaWiki のコンテナに接続して更新スクリプトを実行する。更新スクリプトは /var/www/html/maintenance/update.php だ。

takatoh@apostrophe:testwiki$ docker exec -it testwiki bash
root@f92dd50471e5:/var/www/html# cd maintenance
root@f92dd50471e5:/var/www/html/maintenance# php update.php

これで完了。

[追記]

11/7、本番環境も無事 Docker 上に移行した。

ひとそろいの画像ファイルからEPUB(電子書籍)ファイルを作る

Qiita の↓の記事を読んで、EPUB って結構簡単(もちろん単純なものなら)なんだな、と思ったので Python でスクリプトを作ってみた。

作ったもの

フォルダに入った画像ファイル一式から EPUB ファイルを生成する。

  • 1ページ1画像のファイル一式
  • とりあえず PNG にだけ対応
  • 画像ファイルはファイル名でソートするので連番でなくても構わない
  • 目次とかそういうのはなし
  • 元データのフォルダ名が EPUB のタイトル、ファイル名になる

ファイル構成は次の通り:

takatoh@montana: img2epub > tree /f .
フォルダー パスの一覧
ボリューム シリアル番号は 681C-8AA1 です
C:\USERS\TAKATOH\DOCUMENTS\W\IMG2EPUB
│  .gitignore
│  img2epub.py
│
├─data
│      book.opf.template
│      chap1.xhtml.template
│      container.xml
│      nav.xhtml
│
└─sample
        sample-000.png
        sample-001.png
        sample-002.png
        sample-003.png
        sample-004.png
        sample-005.png
        sample-006.png
        sample-007.png

img2epub.py が Python で書いたスクリプト本体。data フォルダ以下のファイルは EPUB を構成するファイルあるいはそのテンプレート。スクリプトは次の通り:

#!/usr/bin/env python
# encoding: utf-8


import sys
import os
import shutil
import subprocess
from datetime import datetime, timezone
import uuid
import glob
from jinja2 import Template, Environment, FileSystemLoader


def main():
    src_dir = sys.argv[1]
    now = datetime.now(timezone.utc)
    tmp_dir_name = "tmp.epub.{time}".format(time=now.strftime("%Y%m%d%H%M%S"))

    make_dirs(tmp_dir_name)
    images = copy_images(src_dir, tmp_dir_name)
    images = [s.replace("\\","/") for s in sorted(images)]
    gen_mimetype(tmp_dir_name)
    copy_container(tmp_dir_name)
    book_opf_context = {
        "title": src_dir,
        "time": now.isoformat(),
        "images": images
    }
    gen_book_opf(tmp_dir_name, book_opf_context)
    copy_nav(tmp_dir_name)
    gen_chap1_xhtml(tmp_dir_name, book_opf_context)
    zip_epub(tmp_dir_name, src_dir)


def make_dirs(tmp_dir_name):
    os.makedirs(os.path.join(tmp_dir_name, "META-INF"))
    os.makedirs(os.path.join(tmp_dir_name, "EPUB"))


def copy_images(src_dir, tmp_dir_name):
    images_dir = os.path.join(tmp_dir_name, "EPUB/images")
    shutil.copytree(src_dir, images_dir)
    return glob.glob("{dir}/*".format(dir=images_dir))


def gen_mimetype(tmp_dir_name):
    with open(os.path.join(tmp_dir_name, "mimetype"), "w") as f:
        f.write("application/epub+zip")


def copy_container(tmp_dir_name):
    shutil.copyfile("data/container.xml", os.path.join(tmp_dir_name, "META-INF/container.xml"))


def gen_book_opf(tmp_dir_name, context):
    env = Environment(loader=FileSystemLoader("data"))
    template = env.get_template("book.opf.template")
    context["images"] = [s.replace("{tmp}/EPUB".format(tmp=tmp_dir_name), ".") for s in context["images"]]
    context["cover"] = context["images"][0]
    context["uuid"] = str(uuid.uuid4())
    with open(os.path.join(tmp_dir_name, "EPUB/book.opf"), "w") as f:
        f.write(template.render(context))


def copy_nav(tmp_dir_name):
    shutil.copyfile("data/nav.xhtml", os.path.join(tmp_dir_name, "EPUB/nav.xhtml"))


def gen_chap1_xhtml(tmp_dir_name, context):
    env = Environment(loader=FileSystemLoader("data"))
    template = env.get_template("chap1.xhtml.template")
    images = [s.replace("{tmp}/EPUB".format(tmp=tmp_dir_name), ".") for s in context["images"]]
    with open(os.path.join(tmp_dir_name, "EPUB/chap1.xhtml"), "w") as f:
        f.write(template.render(images=images))


def zip_epub(tmp_dir_name, title):
    epub_file_name = "../{title}.epub".format(title=title)
    os.chdir(tmp_dir_name)
    subprocess.run(["zip", "-X0", epub_file_name, "mimetype"], stdout=subprocess.DEVNULL)
    subprocess.run(["zip", "-r9", epub_file_name, "*", "-x", "mimetype"], stdout=subprocess.DEVNULL)
    os.chdir("..")



main()

で、sample フォルダ以下が元になる画像ファイル一式。

使い方

スクリプトの引数に画像一式が入っているフォルダを指定するだけ。

takatoh@montana: img2epub > python img2epub.py sample

そうすると、EPUB ファイル(今回は sample.epub)と、EPUB にまとめる前のファイル一式の入ったフォルダ(同じく tmp.epub.20201027130853)ができる。このフォルダはテンポラリなものなので消しちゃってもいいんだけど、今の段階ではまだ残している。

takatoh@montana: img2epub > ls


    Directory: C:\Users\takatoh\Documents\w\img2epub

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d----          2020/10/27    20:07                data
d----          2020/10/27    20:09                sample
d----          2020/10/27    22:08                tmp.epub.20201027130853
-a---          2020/10/27    20:07              9 .gitignore
-a---          2020/10/27    20:07           2832 img2epub.py
-a---          2020/10/27    22:08        8011218 sample.epub

あとは出来上がった sample.epub ファイルを好きな EPUB ビューワで見ればいい。

EPUB のファイルの構成

EPUB のファイルっていうのは、基本的には使用で決められたファイルとコンテンツを zip で一つにまとめて、拡張子を .epub にしただけのファイルだ。単純に zip にしただけではないんだけど、そのへんはこの記事では触れない。冒頭の Qiita の記事か、EPUB 3.2 の仕様を参照のこと。

今回作ったスクリプト img2epub.py では、tmp.epub.* フォルダにその一式が入っている(つまりこれを zip 圧縮して .epub ファイルを作る)。フォルダの中身は次の通り:

takatoh@montana: img2epub > tree /f tmp.epub.20201027130853
フォルダー パスの一覧
ボリューム シリアル番号は 681C-8AA1 です
C:\USERS\TAKATOH\DOCUMENTS\W\IMG2EPUB\TMP.EPUB.20201027130853
│  mimetype
│
├─EPUB
│  │  book.opf
│  │  chap1.xhtml
│  │  nav.xhtml
│  │
│  └─images
│          sample-000.png
│          sample-001.png
│          sample-002.png
│          sample-003.png
│          sample-004.png
│          sample-005.png
│          sample-006.png
│          sample-007.png
│
└─META-INF
        container.xml

EPUB/images 以下の画像ファイルは、元のデータをコピーしたもの。そのほかのファイルはスクリプトが生成したファイルだ。詳しくは略。

余談

EPUB を構成するファイルの一部(book.opf や chap1.xhtml)を生成するためにテンプレートエンジンを使ってるんだけど、Python には string.Template というテンプレートエンジンが標準でついている。これ、今回調べてて初めて知った。

ところがこの string.Template、単純な値の挿入はできるけど繰り返しや条件分岐の機能がない。今回、条件分岐は使ってないけど繰り返すは必要だったので、結局 Jinja2 を使った。標準添付されてるのはいいけど、変数を値に置き換えるだけしかできないんじゃ、用途は限られるよなぁ。