Apacheのmod_rewriteとBフラグ

PHP のプログラムを Docker コンテナで動かそうとして、Apache の mod_rewrite の設定でちょっと苦労したのでメモ。

主題は PHP じゃないので、プログラムは簡単な例にしておく。クエリーパラメータ name を受け取って、挨拶を返すだけのプログラムだ。

<?php
$name = $_GET["name"];
echo "Hello, " . $name . "!" . PHP_EOL;
?>

Docker イメージは php:8.2-apache を使う。

takatoh@apostrophe:myphpapp$ docker run -d --rm -p 80:80 -v .:/var/www/html php:8.2-apache

これで localhost:80 で待ち受けているはず。curl コマンドで試してみる。

takatoh@apostrophe:myphpapp$ curl http://localhost/hello.php?name=Andy
Hello, Andy!

期待通りだ。

つぎに、PHP プログラムに渡すパラメータ name を、クエリーパラメータではなく、パスの一部にしたい。つまり、http://localhost/hello/Andy という URL でアクセスしたい。

そこで、つぎのような .htaccess ファイルを書いた。

RewriteEngine on
RewriteRule ^hello/(.*)\$ hello.php?name=\$1

それから、Apache で上の .htaccess が有効になるように Dockerfile を書いた。デフォルトでは mod_rewrite 自体が有効になってないんだそうだ。

FROM php:8.2-apache
RUN a2enmod rewrite
RUN sed -ri -e 's!AllowOverride None!AllowOverride FileInfo!g' /etc/apache2/apache2.conf

mod_rewrite を有効にして、なおかつ、.htaccess ファイルでルールを設定するのを許可している。これを元に Docker イメージをビルド。

takatoh@apostrophe:myphpapp$ docker build -t myphpapp:1 .

コンテナを起動する。

takatoh@apostrophe:myphpapp$ docker run -d --rm -p 80:80 -v .:/var/www/html myphpapp:1

さっきと同じように curl で試してみよう。

takatoh@apostrophe:myphpapp$ curl http://localhost/hello/Andy
Hello, Andy!

うまくいった!もちろんクエリーパラーメータで渡すのでも期待通りに動く。

takatoh@apostrophe:myphpapp$ curl http://localhost/hello.php?name=Andy
Hello, Andy!

ところが、パス形式のパラーメータ部分(Andy の部分)に空白文字を使うとうまく動いてくれない(curl で URLエンコードする方法がわからなかったので、空白文字の代わりにエンコードした %20 を使っている)。

takatoh@apostrophe:myphpapp$ curl http://localhost/hello/Andy%20Weir
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>403 Forbidden</title>
</head><body>
<h1>Forbidden</h1>
<p>You don't have permission to access this resource.</p>
<hr>
<address>Apache/2.4.57 (Debian) Server at localhost Port 80</address>
</body></html>

クエリーパラメータ形式では大丈夫。

takatoh@apostrophe:myphpapp$ curl http://localhost/hello.php?name=Andy%20Weir
Hello, Andy Weir!

ブラウザでも試したけど同じ結果。さて、困った。

結論を先に書くと、RewiterRule の最後に B フラグをつければ解決する。Apache の公式ドキュメントにはちゃんと書いてある。が、参考にした日本語の解説ページの中には書いてあるページはなかった。探し方が悪いのかもしれないけど。

ともかく、.htaccess ファイルをつぎのように書き換えた。

RewriteEngine on
RewriteRule ^hello/(.*)\$ hello.php?name=\$1 [B]

再度試してみる。

takatoh@apostrophe:myphpapp$ curl http://localhost/hello/Andy%20Weir
Hello, Andy Weir!

OK!!!

一件落着。

後々のために、Apache のドキュメントから解説を引用しておこう(DeepL 翻訳)。

[B] フラグは、変換を適用する前に英数字以外の文字をエスケープするよう RewriteRule に指示します。

mod_rewrite は URL をマッピングする前にエスケープを解除しなければならないので、 後方参照は適用される時点でエスケープされません。B フラグを使うと、後方参照中の英数字以外の文字がエスケープされます

‘x & y/z’という検索語が与えられた場合、ブラウザはそれを’x%20%26%20y%2Fz’としてエンコードし、リクエストを’search/x%20%26%20y%2Fz’とします。Bフラグがない場合、このリライトルールは’search.php?term=x & y/z’にマップされますが、これは有効なURLではないため、search.php?term=x%20&y%2Fz=としてエンコードされ、意図されたものではありません。

この同じルールにBフラグを設定すると、パラメータは出力URLに渡される前に再エンコードされ、結果として/search.php?term=x%20%26%20%y%2Fzに正しくマッピングされます

Flask-Migrateでハマったけどなんとかなった話

なんとかはなったけど、イマイチ理解はできてない話でもある。

自作の書籍管理WEBアプリに、保管場所を表す BookShelf モデルを追加して、データベース(SQLite3)のアップグレードを実行したところでハマった。

アップグレード前のデータベースには、4つのテーブルがあった。( )内はモデルのクラス名。

  • books (Book)
  • categories (Category)
  • formats (Format)
  • coverarts (CoverArt)

ここに保管場所を記録するための bookshelves テーブルを追加したい。モデルクラスは BookShelf。models.py ファイルにこんなふうに定義した。

class BookShelf(db.Model):
    __tablename__ = 'bookshelves'
    id           = db.Column(db.Integer, primary_key=True)
    name         = db.Column(db.String)
    description  = db.Column(db.String)
    books        = db.relationship('Book', backref='bookshelf', lazy='dynamic')

    def __repr__(self):
        return f'<BookShelf id={self.id} name={self.name}>'

    def to_dictionary(self):
        return {
            'id':          self.id,
            'name':        self.name,
            'description': self.description
        }

app.py ファイルはこうなってる(変更なし)。最後の import views は、ルーティングと対応する処理を定義したモジュールをインポートしている。

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate


app = Flask(__name__)
app.config.from_pyfile('./bruschetta.conf')

db = SQLAlchemy(app)

import models

migrate = Migrate(app, db)

import views

この状態で flask db migrate を実行すれば、データベースをマイグレートするスクリプトを生成してくれる。実行するとこうなった(poetry をパッケージ管理に使ってるので poetry 経由だ)。

takatoh@apostrophe:bruschetta-feature-bookshelf$ poetry run flask db migrate -m "create bookshelves table"
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.

migrations/versions 以下にマイグレート用のスクリプトができるはずだけどできていない。

takatoh@apostrophe:bruschetta-feature-bookshelf$ ls migrations/versions
0bff8cd02ef3_create_tables.py           __pycache__
981c2baab82e_create_coverarts_table.py

もとからあった2つしかない。おかしいと思いつつも flask db upgrade を実行してみたけど、当然のようにエラーになった。

takatoh@apostrophe:bruschetta-feature-bookshelf$ poetry run flask db upgrade
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade 0bff8cd02ef3 -> 981c2baab82e, Create coverarts table
Traceback (most recent call last):
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/sqlalchemy/engine/base.py", line 1969, in _exec_single_context
(以下略)

いや、待て。マイグレート用のスクリプトが作られてないなら、データベースもアップグレードしないんだから、エラーもなく、何もせずに終了するはずでは?

そこで、データベースの中を覗いてみることにした。

takatoh@apostrophe:bruschetta-feature-bookshelf$ sqlite3 instance/bruschetta.db
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
sqlite> .tables
alembic_version  categories       formats        
books            coverarts

テーブルが5つある。bookscategoriesformats は一番始めに作ったテーブル、coverarts はあとから追加したテーブル、そして alembic_version は Flask-Migrate が内部で使っている alembic というデータベースマイグレーション用のライブラリが利用するテーブルだ。

alembic_version に何が格納されているか見てみる(その前にヘッダを表示するようにしておく)。

sqlite> .headers on
sqlite> SELECT * FROM alembic_version;
version_num
0bff8cd02ef3

version_num というカラムが1つだけあって、0bff8cd02ef3 という値(文字列)が格納されている。この値はマイグレーション用スクリプトの識別子で、ファイル名の頭に付いているほか、ファイルの中にも Revision ID の値として書き込まれている。

この 0bff8cd02ef3 という値は、過去2回のマイグレーションのうちの最初の方の値だ。2回目のマイグレーション(coverarts テーブルを追加したとき)にこの値も書き換えられるはず、だと思うんだけどどういうわけかそうならなかったんじゃないか。だとすれば、これを書き換えてやればいいのかも。

sqlite> UPDATE alembic_version SET version_num="981c2baab82e" WHERE version_num="0bff8cd02ef3";
sqlite> SELECT * FROM alembic_version;
version_num
981c2baab82e

これで書き換わった。データベースを抜けて、flask db migrate を改めて実行する。

takatoh@apostrophe:bruschetta-feature-bookshelf$ poetry run flask db migrate -m "create bookshelves table"
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added table 'bookshelves'
INFO  [alembic.autogenerate.compare] Detected added column 'books.bookshelf_id'
INFO  [alembic.autogenerate.compare] Detected added foreign key (bookshelf_id)(id) on table books
INFO  [alembic.autogenerate.compare] Detected added foreign key (coverart_id)(id) on table books
  Generating /home/takatoh/w/Bruschetta/bruschetta-feature-
  bookshelf/migrations/versions/3ccfddd40b69_create_bookshelves_table.py ...  done

おお、今度はうまくいったっぽい。実際、migrations/versions 以下に新しいファイルが生成されている。3ccfddd40b69_create_bookshelves_table.py がそれだ。

takatoh@apostrophe:bruschetta-feature-bookshelf$ ls migrations/versions
0bff8cd02ef3_create_tables.py
3ccfddd40b69_create_bookshelves_table.py
981c2baab82e_create_coverarts_table.py
__pycache__

では、flask db upgrade を実行してみよう。

takatoh@apostrophe:bruschetta-feature-bookshelf$ poetry run flask db upgrade
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade 981c2baab82e -> 3ccfddd40b69, create bookshelves table
Traceback (most recent call last):
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/bin/flask", line 8, in <module>
    sys.exit(main())
             ^^^^^^
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/flask/cli.py", line 1064, in main
    cli.main()
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/click/core.py", line 1078, in main
    rv = self.invoke(ctx)
         ^^^^^^^^^^^^^^^^
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/click/core.py", line 1688, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/click/core.py", line 1688, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
                           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/click/core.py", line 1434, in invoke
    return ctx.invoke(self.callback, **ctx.params)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/click/core.py", line 783, in invoke
    return __callback(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/click/decorators.py", line 33, in new_func
    return f(get_current_context(), *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/flask/cli.py", line 358, in decorator
    return __ctx.invoke(f, *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/click/core.py", line 783, in invoke
    return __callback(*args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/flask_migrate/cli.py", line 150, in upgrade
    _upgrade(directory, revision, sql, tag, x_arg)
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/flask_migrate/__init__.py", line 111, in wrapped
    f(*args, **kwargs)
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/flask_migrate/__init__.py", line 200, in upgrade
    command.upgrade(config, revision, sql=sql, tag=tag)
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/alembic/command.py", line 403, in upgrade
    script.run_env()
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/alembic/script/base.py", line 583, in run_env
    util.load_python_file(self.dir, "env.py")
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/alembic/util/pyfiles.py", line 95, in load_python_file
    module = load_module_py(module_id, path)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/alembic/util/pyfiles.py", line 113, in load_module_py
    spec.loader.exec_module(module)  # type: ignore
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "<frozen importlib._bootstrap_external>", line 940, in exec_module
  File "<frozen importlib._bootstrap>", line 241, in _call_with_frames_removed
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/migrations/env.py", line 96, in <module>
    run_migrations_online()
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/migrations/env.py", line 90, in run_migrations_online
    context.run_migrations()
  File "<string>", line 8, in run_migrations
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/alembic/runtime/environment.py", line 948, in run_migrations
    self.get_context().run_migrations(**kw)
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/alembic/runtime/migration.py", line 627, in run_migrations
    step.migration_fn(**kw)
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/migrations/versions/3ccfddd40b69_create_bookshelves_table.py", line 27, in upgrade
    with op.batch_alter_table('books', schema=None) as batch_op:
  File "/home/takatoh/.pyenv/versions/3.11.7/lib/python3.11/contextlib.py", line 144, in __exit__
    next(self.gen)
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/alembic/operations/base.py", line 398, in batch_alter_table
    impl.flush()
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/alembic/operations/batch.py", line 162, in flush
    fn(*arg, **kw)
  File "/home/takatoh/w/Bruschetta/bruschetta-feature-bookshelf/.venv/lib/python3.11/site-packages/alembic/operations/batch.py", line 669, in add_constraint
    raise ValueError("Constraint must have a name")
ValueError: Constraint must have a name

おおう、またエラーだ。”Constrain must have a name” (制約には名前が必要)だと。ググってみると StackOverflow にズバリの回答を見つけた。

曰く、これは正常な動作で、なぜなら SQLite3 は ALTER table をサポートしてないから。解決法としては、SQLAlchemy のメタデータにすべてのタイプの制約の命名テンプレートを作成するのがいい(今後のためにも)、という。

そういうわけで、app.py をつぎのように書き換えた。

from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from sqlalchemy import MetaData


app = Flask(__name__)
app.config.from_pyfile('./bruschetta.conf')

convention = {
    'ix': 'ix_%(column_0_label)s',
    'uq': 'uq_%(table_name)s_%(column_0_name)s',
    'ck': 'ck_%(table_name)s_%(constraint_name)s',
    'fk': 'fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s',
    'pk': 'pk_%(table_name)s'
}
metadata = MetaData(naming_convention=convention)
db = SQLAlchemy(app, metadata=metadata)

import models

migrate = Migrate(app, db)

import views

そしてさっきのマイグレーションスクリプトを削除して、やり直す。

takatoh@apostrophe:bruschetta-feature-bookshelf$ poetry run flask db migrate -m "create bookshelves table"
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.autogenerate.compare] Detected added column 'books.bookshelf_id'
INFO  [alembic.autogenerate.compare] Detected added foreign key (bookshelf_id)(id) on table books
INFO  [alembic.autogenerate.compare] Detected added foreign key (coverart_id)(id) on table books
  Generating /home/takatoh/w/Bruschetta/bruschetta-feature-
  bookshelf/migrations/versions/1821fe006433_create_bookshelves_table.py ...  done
takatoh@apostrophe:bruschetta-feature-bookshelf$ poetry run flask db upgrade
INFO  [alembic.runtime.migration] Context impl SQLiteImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade 981c2baab82e -> 1821fe006433, create bookshelves table

今度はちゃんとデータベースをアップグレードできたようだ。データベースの中も見てみる。

takatoh@apostrophe:bruschetta-feature-bookshelf$ sqlite3 instance/bruschetta.db
SQLite version 3.31.1 2020-01-27 19:55:54
Enter ".help" for usage hints.
sqlite> .tables
alembic_version  bookshelves      coverarts      
books            categories       formats        
sqlite> .headers on
sqlite> SELECT * FROM alembic_version;
version_num
1821fe006433

bookshelves テーブルもできているし、alembic_version テーブルも更新されている。

これでやっと、WEBアプリの機能追加にかかれる。

Ruby:HTTPクライアントライブラリを http gem にのりかえた

すごーく久しぶりに、Ruby で HTTP アクセスするってことをやった。使い慣れた httpclient を使ったんだけど、こんなエラーが出た。

C:/Ruby32-x64/lib/ruby/gems/3.2.0/gems/httpclient-2.8.3/lib/httpclient/ssl_socket.rb:103:in `connect': SSL_connect returned=1 errno=0 peeraddr=[2606:4700:3032::ac43:8791]:443 state=error: certificate verify failed (certificate has expired) (OpenSSL::SSL::SSLError)
(以下略)

SSLのエラーだ。

ググって調べてみると、httpclient gem は信頼できる証明書を独自に持っていてそれがもうメンテされていない、ということが解った。

上の記事では、システムのデフォルトの証明書を利用することで回避する方法も書かれている。けど、RubyGems.org をみると httpclient gem の最後のリリースは 2016/9 で、これはもう他のライブラリにのりかえるべきだろう。

で、さらにググって調べたところ、http という gem の評判がよさげに見えたのでこれを使ってみることにした。こんな名前よく空いてたな。

使い方は GitHub の Wiki にまとまっている。

httpclient みたいにインスタンスを作る必要がなくて 、require "http" したら HTTP モジュールの関数が使える。例えば GET の場合はこう。

irb(main):001:0> require "http"
=> true
irb(main):002:0> res = HTTP.get("https://blog.panicblanket.com")
=> #<HTTP::Response/1.1 200 OK {"Date"=>"Sat, 06 Jan 2024 20:51:15 GMT", "Content-Type"=>"text/...

レスポンスのコードは #code 、ボディは #to_s で取得できる。

irb(main):003:0> res.code
=> 200
irb(main):004:0> res.to_s
=> "<!DOCTYPE html>\n\n<html lang=\"ja\">\n\n\t<head>\n\n\t\t<meta http-equiv=\"content-type\" content=\"text/html\" charset=\"UTF-8\" />\n\t\t<meta 
(以下略)

こんな感じ。

GETPOSTPUTDELETE などの HTTP メソッドの他にも WEBDAV なんかもサポートしてる。使いやすそうだ。

Dockerコンテナ上のMediaWikiをアップグレードする

つい先日(4日前)に最新版のDocker公式のコンテナイメージ 1.41.0 がリリースされているのを見つけたので、ローカルネットワークで運用している MediaWiki をアップグレードした。これまでのバージョンは 1.35.0。

まずは、データベースのバックアップをとる。データベースもMariaDBのDockerコンテナだ。

takatoh@unclemeat:~$ docker exec -it mywiki-db mysqldump -hlocalhost -P3306 -uroot -prootpass mywiki > mywiki.sql

mywiki-db がデータベースのコンテナ。このコンテナの中で mysqldump コマンドを実行して、mywiki.sql ファイルに書き込んでいる。mysqldump-h オプションはホスト、-P はポート、-u はユーザ、-p はパスワード。-p と引数のあいだに空白を入れるとどういうわけかうまく行かない。

バックアップをとったら、いったんコンテナを止める。

takatoh@unclemeat:~$ cd docker-configuration
takatoh@unclemeat:~/docker-configuration$ docker compose down

docker-compose.yml ファイルを書き換える。MediaWiki イメージのバージョンを1.35.0から1.41.0に書き換えるだけだ。

version: "3"

services:

  mywiki:
    image: mediawiki:1.41.0
    container_name: mywiki
    restart: always
    depends_on:
      - mywiki-db
    volumes:
      - "${CONTAINER_ENV_DIR}/mywiki/images:/var/www/html/images"
      - "${CONTAINER_ENV_DIR}/mywiki/LocalSettings.php:/var/www/html/LocalSettings.php"
      - "${CONTAINER_ENV_DIR}/mywiki/php.ini-production:/usr/local/etc/php/php.ini"
      - "${CONTAINER_ENV_DIR}/mywiki/extensions/YouTube:/var/www/html/extensions/YouTube"
    ports:
      - 9090:80

  mywiki-db:
    image: mariadb:10.5.6-focal
    container_name: mywiki-db
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: rootpass
      MYSQL_DATABASE: mywiki
      MYSQL_USER: wikiuser
      MYSQL_PASSWORD: wikipass
    volumes:
      - "${CONTAINER_ENV_DIR}/mywiki/db:/var/lib/mysql"
    ports:
      - 8836:3306

書き換えが終わったら、コンテナを起動する。

takatoh@unclemeat:~/docker-configuration$ docker compose up -d

問題なく起動したけど、ブラウザでアクセスするとエラーが出ていた。データベースのテーブルにカラムが見つからないらしい。MediaWiki のアップブレードでデータベースのスキーマも更新する必要があるようだ。↓このページが参考になった。

MediaWiki のディレクトリで更新スクリプトを実行すればいいようだ。MediaWiki は Dockerコンテナで動いているので、コンテナの中で実行する。

takatoh@unclemeat:~/docker-configuration$ docker exec -it mywiki bash
root@a423a4fe2c67:/var/www/html# php maintenance/run.php update.php
MediaWiki 1.41.0 Updater

Your composer.lock file is up to date with current dependencies!
Going to run database updates for bsl_wiki
Depending on the size of your database this may take a while!
Abort with control-c in the next five seconds (skip this countdown with --quick) ...0
...collations up-to-date.
(以下略)

無事、更新スクリプトが終了したら、念のためコンテナを再起動する。で、改めてブラウザでアクセスすると、今度はエラーなくページが表示された。

これで完了。

Python:文字列から同じ文字が連続する位置と長さを得る

たとえば、aabbcccdeeee という文字列から、a が0文字目から2文字、b が2文字目から2文字……という情報が欲しい(最初の文字を0文字目とする)。

最初に書いた関数がこれ。

def charpos(s):
    current = None
    result = []
    for i, c in enumerate(s):
        if current is None:
            current = c
            result.append([c, i, 1])
        elif c == current:
            result[-1][2] += 1
        else:
            current = c
            result.append([c, i, 1])
    return result

「文字、位置、長さが入ったリスト」のリストが返ってくる。実行してみるとこうなる。

>>> charpos('aabbcccdeeee')
[['a', 0, 2], ['b', 2, 2], ['c', 4, 3], ['d', 7, 1], ['e', 8, 4]]

期待するものはできた。けど、もっとスマートにいかないものか。分岐の current is Noneelse のコードが重複してるのを整理すればすっきりはするけど、素朴な実装には変わりがない(素朴なのが悪いわけではないとも思うけども)。

あと余談だけど、こういう場合には、外側のリストの要素はリストじゃなくてタプルのほうがいいと思うんだよね。

さて、itertools にある groupby を使ってみた。この関数は iterable なオブジェクトから連続する要素をグループにまとめてくれる(タプルになる)。

>>> import itertools
>>> for g in itertools.groupby('aabbcccdeeee'):
...     print(g)
...
('a', <itertools._grouper object at 0x00000207D778F760>)
('b', <itertools._grouper object at 0x00000207D778F730>)
('c', <itertools._grouper object at 0x00000207D778F760>)
('d', <itertools._grouper object at 0x00000207D778F730>)
('e', <itertools._grouper object at 0x00000207D778F760>)

各タプルの2つ目の要素は itertools._grouper のオブジェクトだけど、リストにしてやると次のようになる。

>>> for g in itertools.groupby('aabbcccdeeee'):
...     print(g[0], list(g[1]))
...
a ['a', 'a']
b ['b', 'b']
c ['c', 'c', 'c']
d ['d']
e ['e', 'e', 'e', 'e']

あとはこれを数えてやればいい。

def charpos2(s):
    idx = 0
    result = []
    for g in itertools.groupby(s):
        l = len(list(g[1]))
        result.append((g[0], idx, l))
        idx += l
    return result
>>> charpos2('aabbcccdeeee')
[('a', 0, 2), ('b', 2, 2), ('c', 4, 3), ('d', 7, 1), ('e', 8, 4)]

まぁ満足。だけどもう少し……というわけで、畳み込み関数を使ってみたらどうだろう。Haskell には mapAccumL っていう関数があるけど、Python だと itertools.accumulate が似た感じだ。こうなった。

def charpos3(s):
    g = itertools.groupby(s)
    acc = itertools.accumulate(g, lambda a, b: (b[0], a[1]+a[2], len(list(b[1]))), initial=('', 0, 0))
    return list(acc)[1:]
>>> charpos3('aabbcccdeeee')
[('a', 0, 2), ('b', 2, 2), ('c', 4, 3), ('d', 7, 1), ('e', 8, 4)]

期待通りの結果は得られたけど、これはかえって解りにくいか。

Python:FletとPyInstallerでGUIアプリを作る

Poetryでプロジェクトを作る。

takatoh@sofa: w > poetry new fletsample
Created package fletsample in fletsample
takatoh@sofa: w > cd fletsample

Fletを追加。

takatoh@sofa: fletsample > poetry add flet

flet create コマンドでプロジェクトを初期化。--templateオプションはひな形の指定で、minimumcounter がある(デフォルトは minimum)。

takatoh@sofa: fletsample > poetry run flet create --template counter .

Copying from template version 0.0.0.post9.dev0+cdc6738
 identical  .
    create  .gitattributes
    create  .gitignore
    create  assets
    create  assets\favicon.png
    create  assets\icon.png
    create  assets\manifest.json
    create  main.py
  conflict  README.md
 Overwrite README.md? [Y/n] y


Done. Now run:

flet run

見ての通りいくつかのファイルが作られる。main.py がメインのファイル。テンプレートに counter を指定したので、ボタンをクリックするとカウンターの数値が変わるアプリの実装が書かれている。

import flet as ft


def main(page: ft.Page):
    page.title = "Flet counter example"
    page.vertical_alignment = ft.MainAxisAlignment.CENTER

    txt_number = ft.TextField(value="0", text_align=ft.TextAlign.RIGHT, width=100)

    def minus_click(e):
        txt_number.value = str(int(txt_number.value) - 1)
        page.update()

    def plus_click(e):
        txt_number.value = str(int(txt_number.value) + 1)
        page.update()

    page.add(
        ft.Row(
            [
                ft.IconButton(ft.icons.REMOVE, on_click=minus_click),
                txt_number,
                ft.IconButton(ft.icons.ADD, on_click=plus_click),
            ],
            alignment=ft.MainAxisAlignment.CENTER,
        )
    )


ft.app(main)

アプリを実行するには、flet run コマンド。新しいウィンドウでアプリが起動する。+ ボタンと - ボタンをクリックするたびに表示されてる数値が1ずつ変わる。そっけないアプリだけどちゃんと動く。

takatoh@sofa: fletsample > poetry run flet run

-w オプションを指定すると、webアプリとして起動し、ブラウザが開く。

takatoh@sofa: fletsample > poetry run flet run -w
http://127.0.0.1:50767

PyInstaller を追加。

takatoh@sofa: fletsample > poetry add --group dev pyinstaller
Using version ^6.1.0 for pyinstaller

Updating dependencies
Resolving dependencies...

The current project's Python requirement (>=3.10,<4.0) is not compatible with some of the required packages Python requirement:
  - pyinstaller requires Python <3.13,>=3.8, so it will not be satisfied for Python >=3.13,<4.0

Because no versions of pyinstaller match >6.1.0,<7.0.0
 and pyinstaller (6.1.0) requires Python <3.13,>=3.8, pyinstaller is forbidden.
So, because fletsample depends on pyinstaller (^6.1.0), version solving failed.

  • Check your dependencies Python requirement: The Python requirement can be specified via the `python` or `markers` properties

    For pyinstaller, a possible solution would be to set the `python` property to ">=3.10,<3.13"

    https://python-poetry.org/docs/dependency-specification/#python-restricted-dependencies,
    https://python-poetry.org/docs/dependency-specification/#using-environment-markers

……なんかエラーが出た。

調べてみると、pyproject.toml ファイルで Python のバージョンを ^3.10 と指定されてるのが原因らしい。PyPI.org の pyinstaller のページには、Python のバージョンについていこんなふうに書いてある。

3.8-3.12. Note that Python 3.10.0 contains a bug making it unsupportable by PyInstaller. PyInstaller will also not work with beta releases of Python 3.13.

pyinstaller 6.1.0 | PyPI.org

これを読むと ^3.10 でよさそうなものだけど……。ともかく、エラーメッセージにあるように、pyproject.toml の中の Python のバージョン指定を書き換えた(3.10.0も避けた)。

python = ">=3.10.1,<3.13"

もう一度、インストール実行。

takatoh@sofa: fletsample > poetry add --group dev pyinstaller

今度は無事インストールできた。ところで、--group dev はパッケージを開発用にインストールする。従来の --dev オプションは非推奨で代わりにこっちを使え、とマニュアルに書いてある。 ともあれ、この時点で pyproject.toml ファイルは次のようになった。

[tool.poetry]
name = "fletsample"
version = "0.1.0"
description = ""
authors = ["takatoh <[email protected]>"]
readme = "README.md"

[tool.poetry.dependencies]
python = ">=3.10.1,<3.13"
flet = "^0.10.3"


[tool.poetry.group.dev.dependencies]
pyinstaller = "^6.1.0"

[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

GUI アプリをビルドするにはメインのソースファイルを指定して flet pack コマンドを実行する。--name オプションは出来上がるファイルの名前を指定している。省略すると mein.exe になる。

takatoh@sofa: fletsample > poetry run flet pack --name fletsample main.py

ビルドされたファイルは dist フォルダの中にできる。

takatoh@sofa: fletsample > ls dist

    Directory: C:\Users\takatoh\Documents\w\fletsample\dist

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a---          2023/10/21    20:40       31291901 fletsample.exe

エクスプローラで探してファイルをダブルクリックすると、GUIアプリが起動した。うまくいってるようだ。試しに、ファイルをほかの場所、例えばデスクトップに移動しても、ダブルクリックすればちゃんと起動する。

Flet はまだバージョン1.0に届いてなくて開発途中のようだけど、簡単なGUIアプリを作るのにはいいんじゃないかと思えた。見た目もモダンでいい。

PyInstaller も初めて使ったけど、Python をインストールしなくてもいいので、他の人に使ってもらうのにはちょうどいいんじゃないかな。ファイルサイズが30MB以上もあるけど、Pythonの実行環境を含んでいるからこれは仕方がないかな。

[追記]

pyproject.toml での Python のバージョン指定、^3.10 だと 3.13 もOKってことになる。だからエラーが出るんだ。

拡張テンパズル

実に久しぶりのエントリ。

テンパズルとは、1桁の数4つと加減乗除を組み合わせて10を作る、っていうパズル。「拡張」とついてるのはルールを拡張してるから。すなわち、

  • 計算式に使う数字は4個でなくても良い。2個以上なら良いものとする
  • 計算式に使う数字は二桁以上でも良い。要するに自然数なら良い
  • 計算結果は10でなくても良い

元ネタはこのブログのエントリ。

さらにその元ネタはQuizKnockのYouTubeチャンネルだそうだ。面白いな、これ。

さて、これを解くプログラムを作ってみた。上に挙げたブログでは Ruby で書いているので、Python で書いた。GitHubに上げてある。

実装上の工夫はあるものの基本的なアルゴリズムは元ネタのブログと同じだ。数と加減乗除からなる計算式を木構造として扱って、その値が10になる答えを探索する。答えは複数ある可能性もあるけど、最初に見つかった答えを返す。

今のところ数4つ、合計が10、に決め打ちで「拡張」にはなってない。とはいえ、解けるようには作ったのでプログラムにオプションを追加するだけでいい。

さて、実装上の工夫について書いておこう。

元ネタの Ruby のプログラムでは、ビルトインの Numeric クラスにvalueメソッドを追加しているけど、オープンクラスでない Python ではそういうことはできない。なので、数をラップするLeafクラスにvalueメソッドを定義した。内部では数をFraction(分数)として保持している。

もうひとつは、答えの木構造を、出力のために文字列に変換するところ。足し算・引き算を掛け算・割り算よりも優先するためにカッコが必要になるんだけど、この部分は元ネタのプログラムよりもすっきりと書けてると思う。

Rails6の最新版までアップグレード

ローカルネットワークで運用してる Rails アプリを Rails6 系の最新版 6.1.7.3 までアップグレードした。Ruby のバージョンは 3.1.4。Docker コンテナ上で動かしている。

以下、メモ。後で時間があればもう少し詳しく書く。

Rails 5.2 以降の credentials.yml.enc について。

Active Storage について。

Rails の設定について。

Python:Pillowでサムネイルの作成に失敗することがある

自宅サーバで運用してる Python で作った webアプリがあって、Pillow で画像のサムネイルを作るようになってるんだけど、ときどきサムネイルの作成に失敗していることがあるのに気がついた。

この webアプリを作ったときにどう考えたのか忘れたけど、もとの画像が png ならサムネイルも png、もとの画像が jpg ならサムネイルも jpg になる。失敗してるのは png のサムネイルを作るとき(の一部)のようだ。サムネイルをつくるのには Pillow の Image.thumbnail() 関数を使ってる。

で、検証のためのスクリプトを書いた。Python と Pillow のバージョンは次の通り。

  • Python 3.10.8
  • Pillow 9.5.0
from PIL import Image
from os import path
import argparse


THUMBNAIL_SIZE = (240, 240)


def main():
    args = parse_arguments()
    orig_file = args.orig
    im = Image.open(orig_file)
    im.thumbnail(THUMBNAIL_SIZE)

    (name, _) = path.splitext(orig_file)
    thumb_file = f'thumb_{name}.{args.format}'
    im.save(thumb_file)
    print(f'Thumbnail has maked successfully: {thumb_file}')


def parse_arguments():
    parser = argparse.ArgumentParser(
        description='Make thumbnail from original image.'
    )
    parser.add_argument(
        'orig',
        action='store',
        help='original image'
    )
    parser.add_argument(
        '-f', '--format',
        action='store',
        default='jpg',
        help='specify thumbnail file format (`jpg` to default)'
    )
    args = parser.parse_args()

    return args


main()

使い方は簡単。もと画像のファイルを引数にして実行すればサムネイルが作られる。--format オプションでサムネイルのフォーマットを指定できる(デフォルトは jpg)。

takatoh@sofa: Documents > python make_thumbnail.py --help
usage: make_thumbnail.py [-h] [-f FORMAT] orig

Make thumbnail from original image.

positional arguments:
  orig                  original image

options:
  -h, --help            show this help message and exit
  -f FORMAT, --format FORMAT
                        specify thumbnail file format (default to `jpg`)

さて、このスクリプトで、用意した png 画像のサムネイルを作ってみる。フォーマットに jpg を指定したときは期待通りにサムネイルが作成される。

takatoh@sofa: Documents > python make_thumbnail.py --format jpg sample1.png
Thumbnail has maked successfully: thumb_sample1.jpg

ところが、フォーマットに png を指定するとエラーになる。

takatoh@sofa: Documents > python make_thumbnail.py --format png sample1.png
Traceback (most recent call last):
  File "C:\Users\takatoh\Documents\make_thumbnail.py", line 42, in <module>
    main()
  File "C:\Users\takatoh\Documents\make_thumbnail.py", line 17, in main
    im.save(thumb_file)
  File "C:\Users\takatoh\AppData\Local\Programs\Python\Python310\lib\site-packages\PIL\Image.py", line 2432, in save
    save_handler(self, fp, filename)
  File "C:\Users\takatoh\AppData\Local\Programs\Python\Python310\lib\site-packages\PIL\PngImagePlugin.py", line 1318, in _save
    data = name + b"\0\0" + zlib.compress(icc)
TypeError: a bytes-like object is required, not 'str'

png のときはいつもエラーになるってわけでもない。下の例のように、ちゃんとサムネイルが作成されることもある。

takatoh@sofa: Documents > python make_thumbnail.py --format png sample2.png
Thumbnail has maked successfully: thumb_sample2.png

どうも、もとの png 画像によってエラーになることがあるらしい。多分まれなケース。ヒントはエラーメッセージに出ている。TypeError だ。bytes-like object でなければならないところで str が来ている。Pillow の PIL/PngImagePlugin.py ファイルの1318行目でエラーになってる。ソースファイルからこの付近を抜き出してみよう。

    icc = im.encoderinfo.get("icc_profile", im.info.get("icc_profile"))
    if icc:
        # ICC profile
        # according to PNG spec, the iCCP chunk contains:
        # Profile name  1-79 bytes (character string)
        # Null separator        1 byte (null character)
        # Compression method    1 byte (0)
        # Compressed profile    n bytes (zlib with deflate compression)
        name = b"ICC Profile"
        data = name + b"\0\0" + zlib.compress(icc)
        chunk(fp, b"iCCP", data)

これは _save() 関数の一部で、下から2行目がファイルの1318行目にあたる。バイト文字列を連結して data 変数に代入してる。name 変数は上の行でバイト文字列のリテラルを代入してるし、b"\0\0" もバイト文字列のリテラルだ。てことは zlib.compress(icc) が怪しい。ちょっと話を端折るけど、icc 変数には im.info.get("icc_profile") で取得した値が入ってる。im は Pillow の Image オブジェクト。

そこで、手を動かしてこれを追いかけてみる。

takatoh@sofa: Documents > python
Python 3.10.8 (tags/v3.10.8:aaaf517, Oct 11 2022, 16:50:30) [MSC v.1933 64 bit (AMD64)] on win32
Type "help", "copyright", "credits" or "license" for more information.
>>> from PIL import Image
>>> import zlib
>>> im = Image.open('sample1.png')
>>> icc = im.info.get("icc_profile")
>>> name = b"ICC Profile"
>>> data = name + b"\0\0" + zlib.compress(icc)
Traceback (most recent call last):
  File "", line 1, in 
TypeError: a bytes-like object is required, not 'str'

サムネイルを作るときと同じエラーが出た。もう少し粒度を細かくしてみよう。zlib.compress(icc) のとこだけ。

>>> icc_compressed = zlib.compress(icc)
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
TypeError: a bytes-like object is required, not 'str'

ああ、つまり iccbytes-like object じゃなくて str なわけだ。

>>> type(icc)
<class 'str'>

やっぱり。

エラーにならなかった sample2.png ファイルでも試してみよう。

>>> im2 = Image.open('sample2.png')
>>> icc2 = im2.info.get("icc_profile")
>>> type(icc2)
<class 'bytes'>

なるほど、ファイルによって im.info.get("icc_profile") で得られる値が bytes だったり str だったりするらしい。で、str だと zlib.compress() でエラーになる、と。

これは Pillow のバグなのか?それとも不適切な png ファイルのせいなのか?

これ以上追いかけるのはちょっと手に余るな。

Ruby:ランダムな文字列を作る

以前、Python で UUID をもとにしてランダムな文字列を作るってのをやった。

同じことを Ruby でやろうとしたら存外に手間を食ったのでメモしておく。

UUID は 標準ライブラリの SecureRandom モジュールで生成できる。

irb(main):001:0> require "securerandom"
=> true
irb(main):002:0> u = SecureRandom.uuid
=> "4ad31376-3c83-49d7-a70a-14467f2b4022"

が、Python では UUID クラスのインスタンスが返ってきてバイト列を得るのも簡単だったけど、Ruby では文字列が返ってくる。なので、16進数表示の文字列からバイト列に変換してやらないといけない。とりあえず邪魔なハイフンを取り除いておく。

irb(main):003:0> h = u.tr("-", "")
=> "4ad313763c8349d7a70a14467f2b4022"

で、バイト列に変換するメソッドを書く。

はじめは2文字ずつに切り分けて String#to_i で整数に変換してやればいいかと思ったけど、これだと得られるのは Fixnum クラスのインスタンス(の配列)であってバイト列じゃない。

結局次のようになった。2文字ずつ切り分けるのは同じだけど、切り出した2文字は別々に整数に変換して、1文字目のほうは 4bit 左シフトしてから2文字目のほうと論理OR をとる。そうすると整数16個の配列ができる。これをバイト列に変換するには Array#pack を使う。

irb(main):004:1* def hex2bstring(hex)
irb(main):005:1*   barray = []
irb(main):006:2*   hex.split("").each_slice(2) do |pair|
irb(main):007:2*     barray.push((pair[0].to_i(16) << 4) | (pair[1].to_i(16)))
irb(main):008:1*   end
irb(main):009:1*   barray.pack("C")
irb(main):010:0> end
=> :hex2bstring

このメソッドを使ってバイト列に変換。

irb(main):011:0> b = hex2bstring(h)
=> "J\xD3\x13v<\x83I\xD7\xA7\n\x14F\x7F+@\"" 

さらに Base32 エンコードするんだけど、Ruby の標準ライブラリには Base32 がないので、あらかじめ gem install base32 しておく必要がある。エンコードしたらパディングの = を取り除く。

irb(main):012:0> require "base32"
=> true
irb(main):013:0> s = Base32.encode(b)
=> "JLJRG5R4QNE5PJYKCRDH6K2AEI======"
irb(main):014:0> s2 = s.tr("=", "")
=> "JLJRG5R4QNE5PJYKCRDH6K2AEI"

最後は見た目のランダムさを増すために小文字を混ぜるようにする。if の条件式には Array#sample を使って [true, false].sample としてもよかったんだけど、せっかく UUID を生成するのに SecureRandom を使ってるのでここでも使ってみた。でもわかりにくいかも。

irb(main):015:1 def down(c)
irb(main):016:2*  if SecureRandom.random_number(2) > 0
irb(main):017:2*    c.downcase
irb(main):018:2*  else
irb(main):019:2*    c
irb(main):020:1*  end
irb(main):021:0> end
=> :down

最終的にはこうなった。

irb(main):022:0> s2.split("").map{|c| down(c) }.join
=> "JljRg5r4qnE5pjyKCRdH6k2aei"

Python より面倒だな。