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アプリの機能追加にかかれる。

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(分数)として保持している。

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

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 ファイルのせいなのか?

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

2つのGitリポジトリを統合する

別々に作った2つの Git リポジトリを統合する方法。違う方法もあると思うけど、調べた結果この方法でできた、ってことで記録しておく。

前提として:

  • 既存の2つのリポジトリ、repo_arepo_b を、新しく作った repo_combined に統合する
  • いずれも Python のプロジェクトで、Poetry を使ってる

まずは新しいプロジェクトを作る。

takatoh@apostrophe:w$ poetry new repo_combined

ディレクトリに移動して、Git の初期化と最初のコミット。このコミットには Poetry がデフォルトで作ってくれるファイルしかない。

takatoh@apostrophe:w$ cd repo_combined
takatoh@apostrophe:repo_combined$ git init
Initialized empty Git repository in /home/takatoh/w/repo_combined/.git/
takatoh@apostrophe:repo_combined$ git add .
takatoh@apostrophe:repo_combined$ git commit -m "First commit."
takatoh@apostrophe:repo_combined$ git branch -M main

リポジトリ repo_a をリモートブランチに取り込む。

takatoh@apostrophe:repo_combined$ git remote add -f repo_a https://github.com/takatoh/repo_a.git

これで、repo_combined の中には main ブランチと、全く繋がりのない repo_a/main が存在する状態になる。で、その repo_a/mainmain にマージする。

takatoh@apostrophe:repo_combined$ git merge repo_a/main
fatal: refusing to merge unrelated histories

……が、失敗した。mainrepo_a/main は全く別もので繋がりがないのでマージできない。マージするには --allow-unrelated-histories オプションをつけてやる。

takatoh@apostrophe:repo_combined$ git merge --allow-unrelated-histories repo_a/main
CONFLICT (add/add): Merge conflict in pyproject.toml
Auto-merging pyproject.toml
Automatic merge failed; fix conflicts and then commit the result.

pyproject.toml にコンフリクトが発生したので、解消してやる。

takatoh@apostrophe:repo_combined$ code pyproject.toml
takatoh@apostrophe:repo_combined$ git add pyproject.toml
takatoh@apostrophe:repo_combined$ git commit -m "Merge repository 'repo_a/main'."

これで repo_a/main のマージが完了。根元が別のコミットツリーが合流するという、ちょっと不思議な感じのするツリーが出来上がる。

つぎは repo_b。これも同じようにすればいい。

takatoh@apostrophe:repo_combined$ git remote add -f repo_b https://github.com/takatoh/repo_b.git

リモートブランチ repo_b/main に取り込んだら、main にマージ(--allow-unrelated-histories をつけて)。

takatoh@apostrophe:repo_combined$ git merge --allow-unrelated-histories repo_b/main
CONFLICT (add/add): Merge conflict in pyproject.toml
Auto-merging pyproject.toml
CONFLICT (add/add): Merge conflict in poetry.lock
Auto-merging poetry.lock
CONFLICT (add/add): Merge conflict in .gitignore
Auto-merging .gitignore
Automatic merge failed; fix conflicts and then commit the result.

当然のようにコンフリクトが発生する(今度はファイル3つ)ので解消してやる。

poetry.lock のコンフリクトを解消するのは厄介だけど、そもそもこのファイルは人間が編集するようなものじゃないので、あとで poetry install で作り直せばすむ。ここでは内容の整合は無視して、とにかくコンフリクトを解消してやればいい。

takatoh@apostrophe:repo_combined$ git add .gitignore
takatoh@apostrophe:repo_combined$ git add pyproject.toml
takatoh@apostrophe:repo_combined$ git add poetry.lock
takatoh@apostrophe:repo_combined$ git commit -m "Merge repository 'repo_b/main'."

で、poetry.lock をいったん削除してから poetry install

takatoh@apostrophe:repo_combined$ git rm poetry.lock
rm 'poetry.lock'
takatoh@apostrophe:repo_combined$ poetry install
takatoh@apostrophe:repo_combined$ git add poetry.lock
takatoh@apostrophe:repo_combined$ git commit -m "poetry.lock: Re-generate."

これでめでたく完了。

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

メモ。

UUID を生成。

>>> import uuid
>>> u = uuid.uuid4()
>>> u
UUID('b7206123-4b26-49ca-b33a-d0fb240c17f9')

Base32エンコードする。

>>> import base64
>>> b = base64.b32encode(u.bytes)
>>> b
b'W4QGCI2LEZE4VMZ22D5SIDAX7E======'

b はバイト文字列なので、文字列に変換。ついでに後ろに付いている = を取り去る。これは文字数合わせのためのパディングなので取ってしまって構わない。

>>> s = b.decode().rstrip('=')
>>> s
'W4QGCI2LEZE4VMZ22D5SIDAX7E'

さらに、見た目のランダムさを増すために、ラテン文字の大文字と小文字を混在させる。そのために、ランダムにラテン文字を小文字に変換する関数を定義。

>>> import random
>>> def down(c):
...     if random.choice([True, False]):
...         return c.lower()
...     else:
...         return c

これを、s の一文字ずつに適用。

>>> random_id = ''.join([ down(c) for c in s ])
>>> random_id
'W4qgCi2LEzE4vMz22D5sidAx7E'

これで OK。やや長いのが難点かな。

>>> len(random_id)
26

Python: 複数の文字列の共通する部分文字列をとりだす

今日は Python。何がしたいかというと、要するに ‘abcde’、’abcxz’、’ab-op’ という文字列から、共通する部分文字列(ただし先頭から) ‘ab’ をとりだしたい。

探せばいいのがあるかと思ったけど、見つからないので書いた。こんなふうになった。

import itertools

def common_substring(*strings):
    def same_all(*args):
        piv = args[0]
        return all([piv == e for e in args[1:]])
    return ''.join([s[0] for s in itertools.takewhile(lambda x: same_all(*x), zip(*strings))])

試してみよう。

>>> s1 = 'abcde'
>>> s2 = 'abcyz'
>>> s3 = 'ab-op'
>>> common_substring(s1, s2, s3)
'ab'

日本語でもいける。

>>> common_substring('こんにちは', 'こんばんは', 'こんちは')
'こん'

可変長引数をとるので、文字列は何個あってもいい。

Python: ローカルネットワー上のプライベートなPyPIリポジトリからpoetry addする on Windows

一昨日、Windows 10 上に pypiserver で立てたプライベートリポジトリから pip コマンドでパッケージをインストールできたので、今日は Poetry が使えるか試してみた。

まずは pypi-server コマンドでサーバを起動してプライベートリポジトリを作っておく。

takatoh@montana: Documents > pypi-server -p 8080 ./py-packages

別のターミナルを開いて、Poetry で新しいプロジェクトを作って中に移動する。

takatoh@montana: Documents > poetry new py-sample
Created package py_sample in py-sample
takatoh@montana: Documents > cd py-sample

Poetry にプライベートリポジトリを登録する。

takatoh@montana: py-sample > poetry config repositories.local http://localhost:8080/simple/

リポジトリの名前が local で、その URL が http://localhost:8080/simple/ だ。

つづいて pyproject.toml ファイルにプライベートリポジトリを参照するように次を追記する。

[[tool.poetry.source]]
name = "local"
url = "http://localhost:8080/simple/"

これで準備は完了。poetry install や poetry add でパッケージをインストールできるはず。あと、キャッシュを削除しておくのを忘れずに(まだ直ってない)。今回は PyPI.org には公開してない、自作の outputdatareader っていうパッケージを追加してみた。

takatoh@montana: py-sample > poetry add outputdatareader
Using version ^0.1.0 for outputdatareader

Updating dependencies
Resolving dependencies...

Writing lock file

Package operations: 11 installs, 0 updates, 0 removals

  • Installing pyparsing (2.4.7)
  • Installing atomicwrites (1.4.0)
  • Installing attrs (21.2.0)
  • Installing colorama (0.4.4)
  • Installing more-itertools (8.10.0)
  • Installing packaging (21.0)
  • Installing pluggy (0.13.1)
  • Installing py (1.10.0)
  • Installing wcwidth (0.2.5)
  • Installing outputdatareader (0.1.0)
  • Installing pytest (5.4.3)

無事インストールできた。

ちなみに、プライベートリポジトリには outputdatareader パッケージしか置いてないから、ほかのパッケージは PyPI.org からインストールしてる。

サーバ側のターミナルにはこんなふうに出力されている。

127.0.0.1 - - [22/Oct/2021 22:41:31] "GET /simple/outputdatareader/ HTTP/1.1" 200 353
127.0.0.1 - - [22/Oct/2021 22:41:33] "GET /simple/outputdatareader/ HTTP/1.1" 200 353
127.0.0.1 - - [22/Oct/2021 22:41:36] "GET /simple/pyparsing/ HTTP/1.1" 303 0
127.0.0.1 - - [22/Oct/2021 22:41:42] "GET /simple/atomicwrites/ HTTP/1.1" 303 0
127.0.0.1 - - [22/Oct/2021 22:41:42] "GET /simple/packaging/ HTTP/1.1" 303 0
127.0.0.1 - - [22/Oct/2021 22:41:42] "GET /simple/more-itertools/ HTTP/1.1" 303 0
127.0.0.1 - - [22/Oct/2021 22:41:42] "GET /simple/py/ HTTP/1.1" 303 0
127.0.0.1 - - [22/Oct/2021 22:41:42] "GET /simple/attrs/ HTTP/1.1" 303 0
127.0.0.1 - - [22/Oct/2021 22:41:42] "GET /simple/pluggy/ HTTP/1.1" 303 0
127.0.0.1 - - [22/Oct/2021 22:41:42] "GET /simple/colorama/ HTTP/1.1" 303 0
127.0.0.1 - - [22/Oct/2021 22:41:42] "GET /simple/wcwidth/ HTTP/1.1" 303 0
127.0.0.1 - - [22/Oct/2021 22:41:48] "GET /simple/outputdatareader/ HTTP/1.1" 200 353
127.0.0.1 - - [22/Oct/2021 22:41:48] "GET /simple/pytest/ HTTP/1.1" 303 0
127.0.0.1 - - [22/Oct/2021 22:41:50] "GET /packages/outputdatareader-0.1.0-py3-none-any.whl HTTP/1.1" 200 2016

outputdatareader 以外のパッケージではステータスコードが 303 (See Other)になってる。つまりプライベートリポジトリには無いってことで、そうすると Poetry は PyPI.org に探しに行く。最後の行から、プライベートリポジトリからは outputdatareader-0.1.0-py3-none-any.whl ファイルだけがダウンロードされてるのがわかる。

というわけで、今日はここまで。

Python: ローカルネットワーク上のプライベートなPyPIリポジトリからインストールする on Windows

先月同じようなタイトルの記事を書いたけど……

pip コマンドの --find-links オプションを使えば、パッケージファイルを放り込んだディレクトリを指定するだけでインストールすることができた。のだけれど、poetry を使うときにはこの方法ではダメだった。結局のところ PyPI.org 相当のプレイべートなリポジトリサーバを立ててやる必要がある。

選択肢としては pypiserver っていうパッケージがあって、それは知ってたんだけど、設定とか面倒そうだと勝手に思ったので今までやってみなかった。ところが今日試してみたら存外に簡単にできたのでメモしておくことにする。記事のタイトルにもあるように環境は Windows。

pypiserver 自体は pip コマンドでインストールできる。

takatoh@montana: Documents > pip install pypiserver
Looking in links: http://nightschool/py-packages/
Collecting pypiserver
  Using cached pypiserver-1.4.2-py2.py3-none-any.whl (77 kB)
Installing collected packages: pypiserver
Successfully installed pypiserver-1.4.2

これで完了。以下のようにインストールされてるのが確認できる。

takatoh@montana: Documents > pip list | grep pypiserver
pypiserver                        1.4.2

つぎに、パッケージファイルを置いておくフォルダを作る。

takatoh@montana: Documents > mkdir py-packages

    Directory: C:\Users\takatoh\Documents

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
d----          2021/10/19    20:32                py-packages

で、ここに自作のパッケージファイルを放り込んだ。

takatoh@montana: Documents > ls py-packages

    Directory: C:\Users\takatoh\Documents\py-packages

Mode                 LastWriteTime         Length Name
----                 -------------         ------ ----
-a---          2021/10/17    18:51           4035 brs-0.8.1-py3-none-any.whl

あとは pypi-server コマンドで起動すれば準備は完了。

takatoh@montana: Documents > pypi-server -p 8080 ./py-packages

-p 8080 オプションでポートを、引数でパッケージファイルの入っているフォルダを指定している。

さて、別のコンソールを立ち上げて pip install してみる。--extra-index-url http://localhost:8080/simple/ オプションでローカルホストに立てた pypiserver の URL を指定している。

takatoh@montana: takatoh > pip install --extra-index-url http://localhost:8080/simple/ brs
Looking in indexes: https://pypi.org/simple, http://localhost:8080/simple/
Looking in links: http://nightschool/py-packages/
Collecting brs
  Downloading http://localhost:8080/packages/brs-0.8.1-py3-none-any.whl (4.0 kB)
Requirement already satisfied: click<9.0.0,>=8.0.1 in c:\users\takatoh\appdata\local\programs\python\python39\lib\site-packages (from brs) (8.0.1)
Requirement already satisfied: PyYAML<6.0.0,>=5.4.1 in c:\users\takatoh\appdata\local\programs\python\python39\lib\site-packages (from brs) (5.4.1)
Requirement already satisfied: requests<3.0.0,>=2.26.0 in c:\users\takatoh\appdata\local\programs\python\python39\lib\site-packages (from brs) (2.26.0)
Requirement already satisfied: colorama in c:\users\takatoh\appdata\local\programs\python\python39\lib\site-packages (from click<9.0.0,>=8.0.1->brs) (0.4.4)
Requirement already satisfied: idna<4,>=2.5 in c:\users\takatoh\appdata\local\programs\python\python39\lib\site-packages (from requests<3.0.0,>=2.26.0->brs) (3.2)
Requirement already satisfied: certifi>=2017.4.17 in c:\users\takatoh\appdata\local\programs\python\python39\lib\site-packages (from requests<3.0.0,>=2.26.0->brs) (2021.5.30)
Requirement already satisfied: urllib3<1.27,>=1.21.1 in c:\users\takatoh\appdata\local\programs\python\python39\lib\site-packages (from requests<3.0.0,>=2.26.0->brs) (1.26.7)
Requirement already satisfied: charset-normalizer~=2.0.0 in c:\users\takatoh\appdata\local\programs\python\python39\lib\site-packages (from requests<3.0.0,>=2.26.0->brs) (2.0.6)
Installing collected packages: brs
Successfully installed brs-0.8.1

Looking in indexes: で始まる行で、PyPI.org とローカルに立てた pypiserver を見に行ってるのがわかる。つづく Looking in links: で始まる行では、先日の記事で設定したパッケージファイルの置いてある場所を探しに行っている。結果として、ローカルの pypiserver からダウンロードしてインストールしてるのがわかる。

サーバ側にはこんなふうに出力されていた。

127.0.0.1 - - [19/Oct/2021 20:35:27] "GET /simple/ HTTP/1.1" 200 207
127.0.0.1 - - [19/Oct/2021 20:35:27] "GET /favicon.ico HTTP/1.1" 404 711
127.0.0.1 - - [19/Oct/2021 20:36:42] "GET /simple/brs/ HTTP/1.1" 200 301
127.0.0.1 - - [19/Oct/2021 20:36:45] "GET /packages/brs-0.8.1-py3-none-any.whl HTTP/1.1" 200 4035

というわけで、Windows でもローカルに pypiserver を立てるのは簡単だということが分かった。

あとはこれをサービスとしてバックグラウンドで動かせれば、poetry からも使えるようになる(はず)。それはまた今度。