Linux Mint で mise を使う

OS のインストールが済んだら次はツール、というかプログラミング言語のインストールだ。よく使う Python や Ruby なんかをインストールする。

で、今までは pyenv とか rbenv を使ってたんだけど、最近見つけた mise (ミース)を使ってみようと思った。

mise は Rust 製のツールなので、まずは Rust をインストールする。公式サイトの手順に従えばいい。rustup という Rust のツールチェインを管理するツールをまずインストールする。

takatoh@apostrophe:~$ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
info: downloading installer

Welcome to Rust!

This will download and install the official compiler for the Rust
programming language, and its package manager, Cargo.

Rustup metadata and toolchains will be installed into the Rustup
home directory, located at:

/home/takatoh/.rustup

This can be modified with the RUSTUP_HOME environment variable.

The Cargo home directory is located at:

/home/takatoh/.cargo

This can be modified with the CARGO_HOME environment variable.

The cargo, rustc, rustup and other commands will be added to
Cargo's bin directory, located at:

/home/takatoh/.cargo/bin

This path will then be added to your PATH environment variable by
modifying the profile files located at:

/home/takatoh/.profile
/home/takatoh/.bashrc

You can uninstall at any time with rustup self uninstall and
these changes will be reverted.

Current installation options:


default host triple: x86_64-unknown-linux-gnu
default toolchain: stable (default)
profile: default
modify PATH variable: yes

1) Proceed with standard installation (default - just press enter)
2) Customize installation
3) Cancel installation
>

info: profile set to 'default'
info: default host triple is x86_64-unknown-linux-gnu
info: syncing channel updates for 'stable-x86_64-unknown-linux-gnu'
info: latest update on 2024-09-05, rust version 1.81.0 (eeb90cda1 2024-09-04)
info: downloading component 'cargo'
info: downloading component 'clippy'
info: downloading component 'rust-docs'
info: downloading component 'rust-std'
info: downloading component 'rustc'
info: downloading component 'rustfmt'
info: installing component 'cargo'
info: installing component 'clippy'
info: installing component 'rust-docs'
15.9 MiB / 15.9 MiB (100 %) 10.1 MiB/s in 1s ETA: 0s
info: installing component 'rust-std'
26.8 MiB / 26.8 MiB (100 %) 13.5 MiB/s in 2s ETA: 0s
info: installing component 'rustc'
66.9 MiB / 66.9 MiB (100 %) 14.7 MiB/s in 4s ETA: 0s
info: installing component 'rustfmt'
info: default toolchain set to 'stable-x86_64-unknown-linux-gnu'

stable-x86_64-unknown-linux-gnu installed - rustc 1.81.0 (eeb90cda1 2024-09-04)


Rust is installed now. Great!

To get started you may need to restart your current shell.
This would reload your PATH environment variable to include
Cargo's bin directory ($HOME/.cargo/bin).

To configure your current shell, you need to source
the corresponding env file under $HOME/.cargo.

This is usually done by running one of the following (note the leading DOT):
. "$HOME/.cargo/env" # For sh/bash/zsh/ash/dash/pdksh
source "$HOME/.cargo/env.fish" # For fish

Rust に関するツールはすべて ~/.cargo/bin にインストールされる。一旦シェルを終了して起動し直すと(あるいは新しいシェルを起動すると)、cargo コマンドが使えるようになる。

takatoh@apostrophe:~$ cargo --version
cargo 1.81.0 (2dbb1af80 2024-08-20)

rustup show コマンドでツールチェインを確認できる。

takatoh@apostrophe:~$ rustup show
Default host: x86_64-unknown-linux-gnu
rustup home: /home/takatoh/.rustup

stable-x86_64-unknown-linux-gnu (default)
rustc 1.81.0 (eeb90cda1 2024-09-04)

mise のインストールには、mise の公式ドキュメントに従ってインストール用のシェルスクリプトを使う。

takatoh@apostrophe:~$ curl https://mise.run | sh
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 6300 100 6300 0 0 50786 0 --:--:-- --:--:-- --:--:-- 50806
mise: installing mise...
######################################################################## 100.0%
mise: installed successfully to /home/takatoh/.local/bin/mise
mise: run the following to activate mise in your shell:
echo "eval \"\$(/home/takatoh/.local/bin/mise activate bash)\"" >> ~/.bashrc

mise: this must be run in order to use mise in the terminal
mise: run `mise doctor` to verify this is setup correctly

これでインストールできた。

takatoh@apostrophe:~$ ~/.local/bin/mise --version
2024.9.5 linux-x64 (1f0f03e 2024-09-17)

インストールはできたが、アクティベートする必要がある。そのためのコードを .bashrc ファイルに書き込む。

takatoh@apostrophe:~$ echo 'eval "$(~/.local/bin/mise activate bash)"' >> ~/.bashrc

それから環境変数 PATH の設定……なのだけど、アクティベートすると設定しくれるらしい。もう一度シェルを起動し直す。

takatoh@apostrophe:~$ mise --version
2024.9.5 linux-x64 (1f0f03e 2024-09-17)

上の通り、フルパスを指定しなくても mise コマンドが使える。

さて、ここまで来たらプログラミング言語のインストールができる。ドキュメントに従って mise use コマンドで Node.js をインストールしてみる。

takatoh@apostrophe:~$ mise use node@20
mise [email protected] ✓ installed mise ~/.mise.toml tools: [email protected]
takatoh@apostrophe:~$ node --version
v20.17.0

うまく行った!

つぎは Python。

takatoh@apostrophe:~$ mise use [email protected]
mise hint use multiple versions simultaneously with mise use [email protected] [email protected]
mise hint disable this hint with mise settings set disable_hints python_multi or all with mise settings set disable_hints "*"
mise hint installing precompiled python from indygreg/python-build-standalone
if you experience issues with this python (e.g.: running poetry), switch to python-build by running mise settings set python_compile 1
mise hint disable this hint with mise settings set disable_hints python_precompiled or all with mise settings set disable_hints "*"
mise [email protected] ✓ installed mise ~/.mise.toml tools: [email protected]
takatoh@apostrophe:~$ python --version
Python 3.11.10

Ruby はどうか。

takatoh@apostrophe:~$ mise use [email protected]
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 14.6M 100 14.6M 0 0 75.4M 0 --:--:-- --:--:-- --:--:-- 75.6M
BUILD FAILED (Linuxmint 22 on x86_64 using ruby-build 20240917)
You can inspect the build directory at /tmp/ruby-build.20240918205818.20859.tDse9P
See the full build log at /tmp/ruby-build.20240918205818.20859.log
mise ~/.cache/mise/ruby/ruby-build/bin/ruby-build failed
==> Downloading openssl-3.0.15.tar.gz...
-> curl -q -fL -o openssl-3.0.15.tar.gz https://dqw8nmjcqpjn7.cloudfront.net/23c666d0edf20f14249b3d8f0368acaee9ab585b09e1de82107c66e1f3ec9533
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
100 14.6M 100 14.6M 0 0 75.4M 0 --:--:-- --:--:-- --:--:-- 75.6M
==> Installing openssl-3.0.15...
-> ./config "--prefix=$HOME/.local/share/mise/installs/ruby/3.2.5/openssl" "--openssldir=$HOME/.local/share/mise/installs/ruby/3.2.5/openssl/ssl" zlib-dynamic no-ssl3 shared
-> make -j 6

BUILD FAILED (Linuxmint 22 on x86_64 using ruby-build 20240917)

You can inspect the build directory at /tmp/ruby-build.20240918205818.20859.tDse9P
See the full build log at /tmp/ruby-build.20240918205818.20859.log
mise ~/.cache/mise/ruby/ruby-build/bin/ruby-build exited with non-zero status: exit code 1
mise Run with --verbose or MISE_VERBOSE=1 for more information

あれ、なんか失敗したぞ?

でも ruby コマンドは使える。どういうこと?

takatoh@apostrophe:~$ ruby --version
ruby 3.2.3 (2024-01-18 revision 52bb2ac0a6) [x86_64-linux-gnu]

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

[追記]

rubymise でインストールされたものじゃなく、もとから入ってるものだった。

takatoh@apostrophe:~$ which ruby
/usr/bin/ruby

これに対して、mise でインストールされた pythonnode は ~/.local/share/mise/installs/ 以下にある。

takatoh@apostrophe:~$ which python
/home/takatoh/.local/share/mise/installs/python/3.11/bin/python
takatoh@apostrophe:~$ which node
/home/takatoh/.local/share/mise/installs/node/20/bin/node

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 ファイルだけがダウンロードされてるのがわかる。

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