雑記

以下のように Pokemon というクラスを実装したとします。Pokemon クラスは types を渡してインスタンス化しますが、types の長さが 1 か 2 でなかったときに WARNING をロギングし、標準エラー出力します。

import logging
logger = logging.getLogger(__name__)
handler = logging.StreamHandler()  # デフォルトの送信先は標準エラー出力
handler.setLevel(logging.WARNING)
logger.addHandler(handler)

class Pokemon:
    def __init__(self, types):
        if len(types) < 1 or len(types) > 2:
            logger.warning('invalid types %s', types)
        self.types = types

実際に標準エラー出力されると思います。

from pokemon import Pokemon
poke = Pokemon(['Grass', 'Fire', 'Water'])

$ python main.py 2> log.txt

invalid types ['Grass', 'Fire', 'Water']

そこで、Pokemon クラスのテストを pytest で記述することにします。ロギングと標準エラー出力をテストしたいので、組込みフィクスチャの caplog と capfd を利用します。WARNING がロギングされること、標準エラー出力されることのテストは以下のようにかけると思います。

from pokemon import Pokemon
import logging

def test_0(caplog, capfd):
    poke = Pokemon(['Grass', 'Fire', 'Water'])

    assert caplog.record_tuples == [
        ('pokemon', logging.WARNING, 'invalid types [\'Grass\', \'Fire\', \'Water\']'),
    ]

    _, err = capfd.readouterr()
    assert err.strip() == 'invalid types [\'Grass\', \'Fire\', \'Water\']'

しかし、このテストは標準エラー出力が空であるということで失敗します。それも不可解なことに、画面上は意図通りの標準エラー出力がキャプチャされているかのようにみえます。

$ pytest
...
        _, err = capfd.readouterr()
>       assert err.strip() == 'invalid types [\'Grass\', \'Fire\', \'Water\']'
E       assert '' == "invalid type...re', 'Water']"
E         - invalid types ['Grass', 'Fire', 'Water']

test_pokemon.py:12: AssertionError
--------------------------------- Captured stderr call ----------------------------------
invalid types ['Grass', 'Fire', 'Water']
----------------------------------- Captured log call -----------------------------------
WARNING  pokemon:pokemon.py:10 invalid types ['Grass', 'Fire', 'Water']
================================ short test summary info ================================
FAILED test_pokemon.py::test_0 - assert '' == "invalid type...re', 'Water']"
=================================== 1 failed in 0.13s ===================================

よくわからないのでデバッグプリントを仕込んだり仕込まなかったりして pytest を -s オプションで実行すると思います。すると今度はテストが成功します。ついでにデバッグプリントを仕込んだ場合は虚空に消えます。

$ pytest -s
...
collected 1 item

test_pokemon.py .

=================================== 1 passed in 0.01s ===================================

雑記: TeX Live 2021 もインストールしたい話

TeX Live は毎年 TeX Live 2019, TeX Live 2020, TeX Live 2021, … とバージョンが更新されるそうで、だから何なのかというと、年次が変わるともはやそれまで使用していた TeX Live をアップデートして使用することができなくなるようです。
なので、既に TeX Live があるときに隣に新しい TeX Live を入れるとどうなるか(コマンド実行はどうなるのか、ファイルの関連付けはどうなるのか、旧新の統合環境 TeXworks でのコンパイルでは何が参照されるのか)について中途半端にメモしました。TeX をスタートメニューの「TeX Live 20xx」→「TeXworks editor」から使用するような場合は全く意識する必要はない内容です。

  • ユーザ権限で C:\texlive\2021TeX Live 2021 をインストールするとユーザ環境変数の PATH の先頭に C:\texlive\2021\bin\win32 が追加される。よって、既に古い年の TeX Live を C:\texlive\2019 なりにインストールしてそちらにパスを通していた場合、コンソールで ptex2pdf コマンドなりを直接実行するとそれまでの版ではなく 2021 版がよび出されるようになる(ただし古い年の TeX Live へのパスがシステム環境変数に設定されていたらそちらが優先され続けるはずである)。
    • TeX Live 2021 のインストールを管理者権限で行った場合はシステム環境変数の先頭に C:\texlive\2021\bin\win32 が追加されるはずである。
  • 他方、 .tex ファイルを開く既定のプログラムに古い年の TeX Live に同梱されていた TeXworks(Ex. C:\texlive\2019\bin\win32\texworks.exe)を設定していた場合、ファイルの関連付けは勝手には 2021 同梱版には変更されないので適宜 2021 同梱版に変更する必要がある。
    • ただし、TeX Live 2021 のインストーラでインストールを実行する直前に「高度な設定」ダイアログを開けば「ファイルの関連付けを変更」という項目があったようなので、いかにもそこで変更することができたようにみえる。が、高度でない私は「高度な設定」を開かなかったのでわからない。
  • そもそも関連付けの変更云々の前に、古い年の TeX Live 同梱の TeXworks でコンパイルするのと 2021 版に同梱の TeXworks でコンパイルするのとで何が異なるのかだが、TeXworks の「編集」→「設定」→「タイプセット」→「TeXおよび関連プログラムのパス」をみるに、TeX Live 20xx に同梱の TeXworks はデフォルトで以下の優先順位で TeX および関連プログラムを探索しにいくようである(要ドキュメント確認)。なので、TeX Live 2021 をインストールしたからといって TeX Live 2019 以下の TeXworks が 2021 版のプログラムでコンパイルし出すということはない。ただし最新の環境変数の反映を通して 2021 以下も探索範囲に含まれるようになるので、仮に 2019 以下にないファイルを要求するような処理を実行したら 2021 以下が参照されるはずである。もっとも、「TeXおよび関連プログラムのパス」はユーザが編集できるし、ユーザが一度でも編集した場合それ以降最新の環境変数が反映されていくのかは私は編集していないので知らない(要ドキュメント確認)。

雑記: TeX でも会話したい話

TeX でも会話したい

TeX で以下のような表現をしたいとします。tcolorbox で実現できることがわかります。
f:id:cookie-box:20211013121132p:plain
TeX ファイルは以下です。コンパイルするにはお手元に kazusa.png と takumi.png という画像ファイルが必要なので適当な画像を用意してください。

\documentclass[dvipdfmx,b5j,10pt]{jsarticle}

% 色定義
\usepackage[svgnames]{xcolor}
\definecolor{paleiris}{RGB}{229,227,246}
\definecolor{palegold}{RGB}{255,247,204}

% カラーボックス
\usepackage[most,listings]{tcolorbox}
\tcbuselibrary{breakable}

% 独自のセリフボックス定義
\newtcolorbox{SERIFU}[2][]{
    enhanced,  % タイトルボックスの表示に必要
    breakable,  % ページをまたぐのに必要
    fontupper=\color{Brown},  % セリフの文字色を茶色にするために挿入
    arc=7pt,  % 角の丸みの半径
    top=10pt, right=10pt, left=10pt, bottom=10pt,  % パディング
    boxrule=0pt, frame hidden,  % フレーム隠蔽
    grow to left by=-24pt,  % キャラクター画像を表示するため左にマージン
    % タイトルボックスとしてキャラクター画像を表示
    title={\hspace{6pt}},
    attach boxed title to top left={xshift=-28pt, yshift=-38pt},
    boxed title style={
        enhanced,
        arc=18pt,
        top=15pt, right=12pt, left=12pt, bottom=16pt,
        boxrule=0pt, frame hidden,
        underlay={
            \begin{tcbclipinterior}
            \includegraphics[width=38pt]{#2}
            \end{tcbclipinterior}
        },
    },
    #1
}

\color{Brown}  % 地の文を茶色にするために挿入

\begin{document}

\begin{SERIFU}[colback=paleiris]{kazusa.png}
tcolorbox なるパッケージを利用したらこのような表現ができました。
詳しくは https://www.ctan.org/pkg/tcolorbox を参照してください。
\end{SERIFU}

\begin{SERIFU}[colback=palegold]{takumi.png}
これが見通しのいい方法かはわからないけどね。
\end{SERIFU}

\begin{SERIFU}[colback=paleiris]{kazusa.png}
私たちのセリフの文字も茶色にしているし、地の文も茶色にしています。
\end{SERIFU}

\vspace{5pt}
\noindent つづかない

\end{document}

ページをまたぐとフォントの色変更がリセットされてしまう

目的は達成されたかのように思えたのですが、セリフがページをまたぐと以下の欠陥があることがわかります。
f:id:cookie-box:20211013123318p:plain

ページをまたいでもフォントの色変更を維持できないのか

参考文献 2. (2014年の投稿)をみると、そもそも tcolorbox はページをまたいだフォントの色変更維持に対応していなかったようです。しかし、2021 年の現在はどうなのでしょうか。ドキュメントを参照してみます。

ドキュメントの 392 ページ(2021年10月17日現在)に use color stack なるオプションの説明があり、そこに以下のようにあります。

Depending on the LATEX engine and loaded packages, if your text contains some color
changing commands, your color may not survive the break to the next box. For some
engines, there is support for additional color stacks which allow colors to survive breaks.
Such an color stack can be activated by /tcb/use color stack with help of the pdfcol package.

つまりコンパイルするエンジンによっては、use color stack なるオプションを記述すればフォントの色変更維持をしてくれるようです。しかし、色変更してくれる pdfcolpdfTeX 向けのパッケージのようで、私が利用している pLaTeX (ptex2pdf) でこのオプションは利用できなさそうです。試しに breakable, の下に use color stack, と記述してみると以下のメッセージが表示されます(セリフ毎にこのメッセージが出るのでエンターキーで無視し続けると dvi ファイルは出力されますがなぜか pdf にはしてくれないので dvipdfmx main.dvi などとコマンドを打って pdf を出力します。当然色変更は維持されていません)。

Package pgfkeys Error: I do not know the key '/tcb/use color stack' and I am going to ignore it.

なので参考文献 2. の回答にもドキュメントの 392 ページにもあるように、XeLaTeXLuaLaTeX を利用しなければならなさそうです。

そもそも私はいつも TeXworks を通してコンパイルしていて、緑の三角を押すと何が起きているか知らないのですが、私が利用している pLaTeX (ptex2pdf) の設定をみると ptex2pdf というコマンドをよんでいるようです。実際、以下を直接実行しても main.pdf がコンパイルできます。このとき ptex2pdf から pLaTeX がよび出されているらしいのがログからわかります。

$ ptex2pdf -l -ot -kanji=utf8 main.tex

upLaTeX をよび出すには以下のようにしますがこのとき1行目を \documentclass[uplatex,dvipdfmx,b5j,10pt]{jsarticle} と変更しておかないと怒られます。

$ ptex2pdf -u -l -ot -kanji=utf8 main.tex
XeLaTeX で解決する方法

XeLaTex では色変更が維持できるようですが、use color stack オプションが利用できるというわけではなく、\addfontfeatures{Color=Brown} なるコマンドがあるのでそれを利用せよということのようです。何にせよ XeLaTeX で上の文書がコンパイルできるか試してみます。

$ xelatex main.tex

すると、1行目で読み込んでいる jsarticle.cls に対して This file needs format `pLaTeX2e' but this is `LaTeX2e'. という怒られが発生します。そこで TeX WikiXeTeX のページをみると「XeLaTeX で日本語」という項目があり1番目の選択肢として BXjscls の利用が示されています。そのページにしたがって1行目を以下のようにします。すると PDF がコンパイルできます。この時点では色変更は維持されていません。

\documentclass[b5paper,xelatex,ja=standard]{bxjsarticle}

雑記

pytest のフィクスチャとは、以下の擬似コードのように1回だけ yield するジェネレータ関数の形式でテスト時の要請(前処理、ほしいもの、後処理)を登録しておくと、テストに関数名と同名の引数を取ることでテスト実行時に要請が実行され、その引数にほしいものが渡されてくる制度といえると思います(通常の関数の形式でもよく、その場合はしてほしい後処理がないことになります)。pytest 側がテスト実行前にフィクスチャの目印がついた関数(組み込みのものもユーザ定義のものも)を全て読み込んでおいてくれてそのように取り計らってくれるという意味です。テスト社会においてもこういった福利厚生制度が求められているのだと思います。

@pytest.fixture  # これはフィクスチャに登録するという目印
def hoge():  # これがフィクスチャ名
    ...  # テスト前にこれをしてほしい、という要請をかく
    yield x  # テストのときにこれを渡してほしい(あるいは何も要らない)、という要請をかく
    ...  # テスト後にこれをしてほしい、という要請をかく

# テストにフィクスチャ名と同名の引数を設定する( =「フィクスチャをリクエストする」)と
# pytest コマンドでのテスト実行時に要請が実行される
# つまりテストの引数に hoge と記述することでそのテストで上の要請を実行させることができる
# このとき hoge にほしいもの x が格納されてくる(何も yield しなかったら hoge は None である)

より細かいことをいうと、フィクスチャ名はジェネレータ関数名と必ず同名ではなく変更することもできますし、テストの前後処理というのがそのテストの実行前後でするのか1ファイル内の全てのテストの実行前後でするのかといったスコープの概念もありますし、明示的にテストの引数に記述しなくても要請を実行させるオプション autouse などもありますが上記の説明では捨象しています。

# https://docs.pytest.org/en/6.2.x/reference.html#pytest-fixture
@pytest.fixture(scope='session', autouse=True, name='kono_namae_de_yonde')

そして大切なことは、上のように自分でせっせと要請を記述してフィクスチャとして登録しなくても、便利そうな要請が既に色々 pytest に組み込まれているということです。
pytest fixtures: explicit, modular, scalable — pytest documentation

Pytest has useful built-in fixtures, listed here for reference:


以下、上の擬似コードでの hoge() を「フィクスチャジェネレータ関数」、x を「フィクスチャ値」とよぶことにします。実際、pytest のリポジトリ中で後者は _FixtureValue 型とされていると思います。


フィクスチャジェネレータ関数もまたテストのようにフィクスチャを利用することができます。つまり、フィクスチャ名と同名の引数を取ってそのフィクスチャを発動させることができます(ただしスコープに矛盾がなければ――例えば、1つのテストの前後で出して片付ける花瓶をつかって、1ファイル内のすべてのテスト中ずっと薔薇を生けておくことはできません)。

import pytest

@pytest.fixture
def kabin():
    print('花瓶を用意します')
    yield []
    print('花瓶を片付けます')

@pytest.fixture  # @pytest.fixture(scope='module') とすると ScopeMismatch エラー
def bara_wo_iketa_kabin(kabin):
    print('花瓶に薔薇を生けます')
    kabin.append('薔薇')
    yield kabin
    print('花瓶から薔薇を取り除きます')

def test_1(bara_wo_iketa_kabin):
    print('テストします')
    assert bara_wo_iketa_kabin == ['薔薇']
$ pipenv run pytest -s
花瓶を用意します
花瓶に薔薇を生けます
テストします
花瓶から薔薇を取り除きます
花瓶を片付けます

雑記: pytest の monkeypatch の話

pytest でテストをするとき monkeypatch でモジュールや環境変数をモックすると思います。一昨日の記事のように pytest が利用可能な pipenv 環境を構築すると以下の(無意味な)テストが実行できると思います。

import os

def test_fuga(monkeypatch):
    monkeypatch.setenv("USER", "TestingUser")
    assert os.environ["USER"] == "TestingUser"
$ pipenv run pytest test_hoge.py


なので pytest というコマンドを実行すると「monkeypatch という何か便利なものをどこかからもってきて」「関数 test_fuga を実行する」ということを勝手にやってくれるのですがなんか怖いのでどうなっているか確認したいと思います。pytest というコマンドを実行すると以下のように処理が進んでいくと思います。
https://github.com/pytest-dev/pytest/blob/6.2.5/setup.cfg#L64
https://github.com/pytest-dev/pytest/blob/6.2.5/src/_pytest/config/__init__.py#L178
https://github.com/pytest-dev/pytest/blob/6.2.5/src/_pytest/config/__init__.py#L130
https://github.com/pytest-dev/pytest/blob/6.2.5/src/_pytest/config/__init__.py#L297
https://github.com/pytest-dev/pytest/blob/6.2.5/src/_pytest/config/__init__.py#L263
https://github.com/pytest-dev/pytest/blob/6.2.5/src/_pytest/config/__init__.py#L678
https://github.com/pytest-dev/pytest/blob/6.2.5/src/_pytest/config/__init__.py#L703
この行でちゃんと以下のモジュール(monkeypatch が実装されている)がインポートされていて安心すると思います(monkeypatch 以外にも組み込みフィクスチャが色々インポートされていますが)。

_pytest.monkeypatch


テストの実行は上の L130 の関数まで戻って以下らへんになると思います。面倒になってきました。
https://github.com/pytest-dev/pytest/blob/6.2.5/src/_pytest/config/__init__.py#L162
https://github.com/pytest-dev/pytest/blob/6.2.5/src/_pytest/main.py#L319
https://github.com/pytest-dev/pytest/blob/6.2.5/src/_pytest/main.py#L336


それで肝心の monkeypatch の実装は以下であり、MonkeyPatch クラスのインスタンスを1回だけ取り出せるジェネレータ関数であることがわかると思います。2回目の取り出しをしようとすると(取り出せないが)インスタンスの undo というメソッドがよばれるようです。明らかにモックしたモジュールや環境変数を元に戻しているのでしょう。
https://github.com/pytest-dev/pytest/blob/6.2.5/src/_pytest/monkeypatch.py#L29-L51
しかし、冒頭の test_hoge.py 内の monkeypatch はジェネレータ関数としてふるまっているようにみえません。monkeypatch ジェネレータ関数にはよくみると fixture というデコレータが付いています。その実装は以下にあります。
https://github.com/pytest-dev/pytest/blob/6.2.5/src/_pytest/fixtures.py#L1263-L1335
fixture デコレータは関数に _pytestfixturefunction というアトリビュートを付けるだけのようです。
https://github.com/pytest-dev/pytest/blob/6.2.5/src/_pytest/fixtures.py#L1201
そのファイルを読むと、テストに monkeypatch と記述するとジェネレータ関数の monkeypatch が得られるのではなく、「monkeypatch をコールして得たイテレータから MonkeyPatch クラスのインスタンスを取り出したもの」が私たちのもとに届けられているようです(以下)。
https://github.com/pytest-dev/pytest/blob/6.2.5/src/_pytest/fixtures.py#L923-L925
つまり、上のファイルを読むと、 _pytestfixturefunction というアトリビュートをつけた関数がテストから取り寄せられたとき、それがジェネレータ関数であるならば(yield の数は1つだけでないと駄目です)、テストのセットアップ時にそれをコールしたイテレータからその1つ目の値が取り出され、テスト後に yield より後の処理がなされるらしいことがわかると思います。ジェネレータ関数でなく通常の関数でも構いません(「イテレータの1つ目の値」が単に「関数の戻り値」になり、後処理がなくなるだけです)。


ちなみに pytest のリポジトリを手元にチェックアウトして以下のように Pipfile をかくと pytest のコードに直接デバッグプリントを仕込みながら pytest を実行できますが(pipenv でなくてもできるが)、このとき単に pipenv run pytest とテスト対象ファイルを指定せずに実行すると手元の pytest ディレクトリ以下までテストが探索され、リポジトリに同梱されているサンプルスクリプトからエラーが出てびっくりすると思います。

...

[packages]
pytest = {path = "./pytest"}

...