Tak's Notebook

Kaggle, Machine Learning, Engineering

click を使ったエラーケースのテスト方法

背景

コマンドラインインターフェイスclick のテスト方法についてのメモ

実装

コード

以下のコードをテストしたい場合を考える。

# src/main.py
import click


@click.group()
def cli():
    pass


@cli.command()
@click.option("--name", type=str, required=True, help="The person to greet.")
def hello(name):
    if name == "Bob":
        raise ValueError("Sorry, Bob is not allowed.")
    click.echo(f"Hello {name}!")


@cli.command()
@click.option("--name", type=str, required=True, help="The person to greet.")
def goodbye(name):
    click.echo(f"Goodbye {name}!")
# src/__main__.py
from src.main import cli


if __name__ == "__main__":
    cli()

通常の実行例は以下のようになる想定。

python -m src hello --name Alice

テスト方法

click.testing.CliRunner

click のテストでは click.testing.CliRunner を使用する。

Testing Click Applications — Click Documentation (8.1.x)

上記の例では Bob という引数が来たらエラーになるので、そのテストをしたいとする。

Pytest でのエラーケースのテストは pytest.raises を使うので、同様のコードを書くと以下のようになる。

import pytest
from click.testing import CliRunner


def test_cli(name):
    from src.main import cli

    runner = CliRunner()
    with pytest.raises(ValueError):
        runner.invoke(cli, ["hello", "--name", "Bob"])

しかし、このテストを実行するとエラーが出る。

CliRunner.invoke 自体の実行はエラーが生じないためと思われる。

>       with pytest.raises(ValueError):
E       Failed: DID NOT RAISE <class 'ValueError'>

tests/test_main.py:9: Failed


click.testing.Result

click のテストでエラーが生じる場合のテストは CliRunner.invoke の返り値を利用する

import pytest
from click.testing import CliRunner


def test_cli():
    from src.main import cli

    runner = CliRunner()
    result = runner.invoke(cli, ["hello", "--name", "Bob"])
    assert result.exit_code != 0
    assert isinstance(result.exception, ValueError)
    assert str(result.exception) == "Sorry, Bob is not allowed."

https://github.com/pallets/click/blob/cab9483a30379f9b8e3ddb72d5a4e88f88d517b6/src/click/testing.py#L104-L133

以上を踏まえて、正常系と異常系のテストケースをまとめると以下のようになる。

import pytest
from click.testing import CliRunner


@pytest.mark.parametrize(
    "name, expected_error",
    [
        ("Alice", None),
        ("Bob", ValueError("Sorry, Bob is not allowed.")),
    ],
)
def test_cli(name, expected_error):
    from src.main import cli

    runner = CliRunner()
    result = runner.invoke(cli, ["hello", "--name", name])
    if expected_error:
        assert result.exit_code != 0
        assert isinstance(result.exception, ValueError)
        assert str(result.exception) == str(expected_error)
    else:
        assert result.exit_code == 0
        assert result.output == f"Hello {name}!\n"

まとめ

  • click のテストではclick.testing.CliRunner を使用する
  • 異常系のテストでは CliRunner.invoke の返り値の Result.exit_codeResult.exception を利用する

2023年良かったもの

2023年良かったもの

💻ガジェット

  • なし

♬音楽

📘本

📻ラジオ

その他

cargo build 時に error: failed to run custom build command for `openssl-sys v0.9.97` が出たときの対処法

エラーメッセージ

cargo-lambda でビルドしようとしたときに以下のようなエラーが出た

$ cargo lambda build --release --arm64 --bin lambda
   Compiling openssl-sys v0.9.97
error: failed to run custom build command for `openssl-sys v0.9.97`

Caused by:
  process didn't exit successfully: `$PWD/target/release/build/openssl-sys-84db175de3593bc2/build-script-main` (exit status: 101)
  --- stdout
  cargo:rerun-if-env-changed=AARCH64_UNKNOWN_LINUX_GNU_OPENSSL_LIB_DIR
  AARCH64_UNKNOWN_LINUX_GNU_OPENSSL_LIB_DIR unset
  cargo:rerun-if-env-changed=OPENSSL_LIB_DIR
  OPENSSL_LIB_DIR unset
  cargo:rerun-if-env-changed=AARCH64_UNKNOWN_LINUX_GNU_OPENSSL_INCLUDE_DIR
  AARCH64_UNKNOWN_LINUX_GNU_OPENSSL_INCLUDE_DIR unset
  cargo:rerun-if-env-changed=OPENSSL_INCLUDE_DIR
  OPENSSL_INCLUDE_DIR unset
  cargo:rerun-if-env-changed=AARCH64_UNKNOWN_LINUX_GNU_OPENSSL_DIR
  AARCH64_UNKNOWN_LINUX_GNU_OPENSSL_DIR unset
  cargo:rerun-if-env-changed=OPENSSL_DIR
  OPENSSL_DIR unset
  cargo:rerun-if-env-changed=OPENSSL_NO_PKG_CONFIG
  cargo:rerun-if-env-changed=PKG_CONFIG_ALLOW_CROSS_aarch64-unknown-linux-gnu
  cargo:rerun-if-env-changed=PKG_CONFIG_ALLOW_CROSS_aarch64_unknown_linux_gnu
  cargo:rerun-if-env-changed=TARGET_PKG_CONFIG_ALLOW_CROSS
  cargo:rerun-if-env-changed=PKG_CONFIG_ALLOW_CROSS
  cargo:rerun-if-env-changed=PKG_CONFIG_aarch64-unknown-linux-gnu
  cargo:rerun-if-env-changed=PKG_CONFIG_aarch64_unknown_linux_gnu
  cargo:rerun-if-env-changed=TARGET_PKG_CONFIG
  cargo:rerun-if-env-changed=PKG_CONFIG
  cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR_aarch64-unknown-linux-gnu
  cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR_aarch64_unknown_linux_gnu
  cargo:rerun-if-env-changed=TARGET_PKG_CONFIG_SYSROOT_DIR
  cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR
  run pkg_config fail: pkg-config has not been configured to support cross-compilation.

  Install a sysroot for the target platform and configure it via
  PKG_CONFIG_SYSROOT_DIR and PKG_CONFIG_PATH, or install a
  cross-compiling wrapper for pkg-config and set it via
  PKG_CONFIG environment variable.

  --- stderr
  thread 'main' panicked at '

  Could not find directory of OpenSSL installation, and this `-sys` crate cannot
  proceed without this knowledge. If OpenSSL is installed and this crate had
  trouble finding it,  you can set the `OPENSSL_DIR` environment variable for the
  compilation process.

  Make sure you also have the development packages of openssl installed.
  For example, `libssl-dev` on Ubuntu or `openssl-devel` on Fedora.

  If you're in a situation where you think the directory *should* be found
  automatically, please open a bug at https://github.com/sfackler/rust-openssl
  and include information about your system as well as this message.

  $HOST = aarch64-apple-darwin
  $TARGET = aarch64-unknown-linux-gnu
  openssl-sys = 0.9.97

  ', $HOME/.cargo/registry/src/index.crates.io-6f17d22bba15001f/openssl-sys-0.9.97/build/find_normal.rs:190:5
  note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

対応策

エラーメッセージ中の以下の箇所に注目して対応してみる

Install a sysroot for the target platform and configure it via PKG_CONFIG_SYSROOT_DIR and PKG_CONFIG_PATH, or install a cross-compiling wrapper for pkg-config and set it via PKG_CONFIG environment variable.

pkg-config のクロスコンパイルラッパーをインストールして PKG_CONFIG という環境変数にセットする

brew install pkg-config-wrapper

formulae.brew.sh

pkg-config-wrapper のパスを確認する

$ which pkg-config-wrapper
/opt/homebrew/bin/pkg-config-wrapper

PKG_CONFIG という環境変数に確認したパスを設定する

export PKG_CONFIG=/opt/homebrew/bin/pkg-config-wrapper

ビルドが通った

$ cargo lambda build --release --arm64 --bin lambda
   Compiling openssl-sys v0.9.97
   Compiling openssl v0.10.61

Cargo Lambda で AWS Lambda 用の Rust バイナリファイルをビルドする

背景

個人的に家計管理のために Slack に投稿すると IFTTT 経由でスプレッドシートに転記するような AWS Lambda を作って使っていた。

構成図イメージ

Go 1.x が Deprecation になるのに合わせて Go で書いていたアプリケーションを Rust に移植してみた。 そのときの手順やハマったポイントについて書き残しておく。

ちなみに Python 版の話は以前ブログで書いた。

takaishikawa42.hatenablog.com

方法

Rust で書かれた AWS Lambda 関数を便利にビルド・デプロイできるパッケージが AWS 公式でリリースされている。 基本的に手順は README を読みながら進めれば問題なくビルド・デプロイできる。

github.com

Cargo Lambda 自体のドキュメントは以下にまとめられている。

www.cargo-lambda.info

手順

1. Cargo Lambda をインストールする

brew tap cargo-lambda/cargo-lambda
brew install cargo-lambda

2. Lambda 関数を実装する

Cargo Lambda のサブコマンド new を利用すると雛形が作られるので、それをもとに修正を行う。

cargo lambda new $LAMBDA_PACKAGE_NAME

以下のインタラクティブを経て生成される。

? Is this function an HTTP function? (y/N)  
[type `yes` if the Lambda function is triggered by an API Gateway, Amazon Load Balancer(ALB), or a Lambda URL]

? AWS Event type that this function receives  
  activemq::ActiveMqEvent
  autoscaling::AutoScalingEvent
  chime_bot::ChimeBotEvent
  cloudwatch_events::CloudWatchEvent
  cloudwatch_logs::CloudwatchLogsEvent
  cloudwatch_logs::CloudwatchLogsLogEvent
v codebuild::CodeBuildEvent
[↑↓ to move, tab to auto-complete, enter to submit. Leave it blank if you don't want to use any event from the aws_lambda_events crate]

1つ目の質問を No、2つ目の質問を空白で答えると以下のようなコードが生成される

use lambda_runtime::{run, service_fn, Error, LambdaEvent};

use serde::{Deserialize, Serialize};

/// This is a made-up example. Requests come into the runtime as unicode
/// strings in json format, which can map to any structure that implements `serde::Deserialize`
/// The runtime pays no attention to the contents of the request payload.
#[derive(Deserialize)]
struct Request {
    command: String,
}

/// This is a made-up example of what a response structure may look like.
/// There is no restriction on what it can be. The runtime requires responses
/// to be serialized into json. The runtime pays no attention
/// to the contents of the response payload.
#[derive(Serialize)]
struct Response {
    req_id: String,
    msg: String,
}

/// This is the main body for the function.
/// Write your code inside it.
/// There are some code example in the following URLs:
/// - https://github.com/awslabs/aws-lambda-rust-runtime/tree/main/examples
/// - https://github.com/aws-samples/serverless-rust-demo/
async fn function_handler(event: LambdaEvent<Request>) -> Result<Response, Error> {
    // Extract some useful info from the request
    let command = event.payload.command;

    // Prepare the response
    let resp = Response {
        req_id: event.context.request_id,
        msg: format!("Command {}.", command),
    };

    // Return `Response` (it will be serialized to JSON automatically by the runtime)
    Ok(resp)
}

#[tokio::main]
async fn main() -> Result<(), Error> {
    tracing_subscriber::fmt()
        .with_max_level(tracing::Level::INFO)
        // disable printing the name of the module in every log line.
        .with_target(false)
        // disabling time is handy because CloudWatch will add the ingestion time.
        .without_time()
        .init();

    run(service_fn(function_handler)).await
}

生成された function_handler 関数が実行のメインなのでここに実装を行う。 別パッケージで処理を行うハンドラを書いておいて、それを呼び出すだけにした方が実装が簡潔になると思う。

注意点としては、 aws lambda invoke で --payload の key になる値に Request 構造体のフィールド名を一致させる必要がある。

3. Lambda 関数をビルドする

Cargo Lambda のサブコマンド build でビルドできる。

--release オプションでリリースモードでビルドされるところや、--arm64 オプションで arm64 アーキでビルドされる(--target aarch64-unknown-linux-gnu の省略形)ところは通常の cargo と同じ。 ちなみに --output-format でバイナリファイルが Zip された状態で生成することもできる。後述の cargo deploy を使わずにデプロイする場合には指定すると良いかもしれない。

cargo lambda build --release --arm64 --bin $LAMBDA_PACKAGE_NAME

4. Lambda 関数をデプロイする

cargo lambda のサブコマンド deploy でデプロイできる。

環境変数AWS CLI のように一つ一つ指定しなくても --env-file オプションで KEY=VALUE フォーマットで記述された .env ファイルのパスを渡すだけで設定できる。 Lambda の実行ロールは指定しなければ、デフォルトのロールが作成される。また細かい設定(ハンドラ名、Lambda パッケージのパス、アーキテクチャ等)の指定も不要。

cargo lambda deploy $LAMBDA_FUNCTION_NAME --env-file .env

5. Lambda 関数を実行する(テストする)

実行も cargo lambda のサブコマンド invoke で実行できる。

--remote オプションですでに AWS Lambda にデプロイされた関数を実行できる。指定しない場合は(デフォルトでは)ローカルのエミュレータに対してリクエストを送ってしまう*1ので注意する必要がある。 また payload が不要な場合でも version 1.0.0 時点では何らかのデータフラグ(--data-file, --data-ascii, --data-example のいずれか)の指定が必要になっている。

cargo lambda invoke $LAMBDA_FUNCTION_NAME --remote --output-format json --data-ascii "{}"

ハマったポイント

いくつか実装中にハマったポイントがあったので列挙する。

1. OpenSSL 関連のエラー

OpenSSL 関連のエラーメッセージ例

error: failed to run custom build command for `openssl-sys v0.9.97`

Caused by:
  process didn't exit successfully: `$PWD/target/release/build/openssl-sys-b2444d8dd69cea00/build-script-main` (exit status: 101)
  --- stdout
  cargo:rerun-if-env-changed=AARCH64_UNKNOWN_LINUX_GNU_OPENSSL_LIB_DIR
  AARCH64_UNKNOWN_LINUX_GNU_OPENSSL_LIB_DIR unset
  cargo:rerun-if-env-changed=OPENSSL_LIB_DIR
  OPENSSL_LIB_DIR unset
  cargo:rerun-if-env-changed=AARCH64_UNKNOWN_LINUX_GNU_OPENSSL_INCLUDE_DIR
  AARCH64_UNKNOWN_LINUX_GNU_OPENSSL_INCLUDE_DIR unset
  cargo:rerun-if-env-changed=OPENSSL_INCLUDE_DIR
  OPENSSL_INCLUDE_DIR unset
  cargo:rerun-if-env-changed=AARCH64_UNKNOWN_LINUX_GNU_OPENSSL_DIR
  AARCH64_UNKNOWN_LINUX_GNU_OPENSSL_DIR unset
  cargo:rerun-if-env-changed=OPENSSL_DIR
  OPENSSL_DIR unset
  cargo:rerun-if-env-changed=OPENSSL_NO_PKG_CONFIG
  cargo:rerun-if-env-changed=PKG_CONFIG_ALLOW_CROSS_aarch64-unknown-linux-gnu
  cargo:rerun-if-env-changed=PKG_CONFIG_ALLOW_CROSS_aarch64_unknown_linux_gnu
  cargo:rerun-if-env-changed=TARGET_PKG_CONFIG_ALLOW_CROSS
  cargo:rerun-if-env-changed=PKG_CONFIG_ALLOW_CROSS
  cargo:rerun-if-env-changed=PKG_CONFIG_aarch64-unknown-linux-gnu
  cargo:rerun-if-env-changed=PKG_CONFIG_aarch64_unknown_linux_gnu
  cargo:rerun-if-env-changed=TARGET_PKG_CONFIG
  cargo:rerun-if-env-changed=PKG_CONFIG
  cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR_aarch64-unknown-linux-gnu
  cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR_aarch64_unknown_linux_gnu
  cargo:rerun-if-env-changed=TARGET_PKG_CONFIG_SYSROOT_DIR
  cargo:rerun-if-env-changed=PKG_CONFIG_SYSROOT_DIR
  run pkg_config fail: pkg-config has not been configured to support cross-compilation.

  Install a sysroot for the target platform and configure it via
  PKG_CONFIG_SYSROOT_DIR and PKG_CONFIG_PATH, or install a
  cross-compiling wrapper for pkg-config and set it via
  PKG_CONFIG environment variable.

  --- stderr
  thread 'main' panicked at '

  Could not find directory of OpenSSL installation, and this `-sys` crate cannot
  proceed without this knowledge. If OpenSSL is installed and this crate had
  trouble finding it,  you can set the `OPENSSL_DIR` environment variable for the
  compilation process.

  Make sure you also have the development packages of openssl installed.
  For example, `libssl-dev` on Ubuntu or `openssl-devel` on Fedora.

  If you're in a situation where you think the directory *should* be found
  automatically, please open a bug at https://github.com/sfackler/rust-openssl
  and include information about your system as well as this message.

  $HOST = aarch64-apple-darwin
  $TARGET = aarch64-unknown-linux-gnu
  openssl-sys = 0.9.97

  ', $HOME/.cargo/registry/src/index.crates.io-6f17d22bba15001f/openssl-sys-0.9.97/build/find_normal.rs:190:5
  note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace

この問題は Cargo Lambda のドキュメントにも実は書かれていた。 自分のケースだと reqwest クレートが TLS バックエンドとして OpenSSL を使用しているため rustls feature (あるいは native-tls-vendored feature)を有効にして、TLS バックエンドをアプリケーションに含めるようにする必要があるらしい。

Cross Compiling with Cargo Lambda | Cargo Lambda

というのも AWS Lambda は Linux サンドボックス環境で関数が実行され、サンドボックスはOSが動作するために必要なライブラリのみ含んでいるため、 *-sys ライブラリ群はバイナリに完全にリンクされているかネイティブの依存関係を他の方法で提供しない限り動作しない可能性があるためらしい。

そのため自分は Cargo.toml の reqwest クレートの記述を修正することでビルドを通すことができた。

-reqwest = { version = "0.11", features = ["blocking", "json"] }
+reqwest = { version = "0.11", default-features = false, features = ["blocking", "json", "rustls-tls"] }

2. Request 構造体のフィールドの DeserializeError

cargo lambda invoke で payload の key になる値に Request 構造体のフィールド名を一致させる必要がある。

payload の値を受けて serde::Deserialize するため DeserializeError が発生する。以下は Request 構造体に command というフィールドがあるにもかかわらず空の payload を渡した場合に起こるエラー例。

$ cargo lambda invoke $LAMBDA_FUNCTION_NAME --remote --data-ascii "{}"
Error: lambda_runtime::deserializer::DeserializeError

  × failed to deserialize the incoming data into the function's payload type: missing field `command` at line 1 column 2

もし payload が不要な場合には Request 構造体はフィールドを持たない空の構造体にする。

3. Invoke サブコマンドで Connection refused (os error 61) エラー

前述の通り、すでに AWS Lambda にデプロイされた関数を実行したい場合には、--remote オプションの指定が必須。

指定しない場合はローカルのエミュレータに対してリクエストを送ってしまい、エミュレータを立ち上げていない場合 Connection refused (os error 61) エラーが発生してしまう。

$ cargo lambda invoke $LAMBDA_FUNCTION_NAME --output-format json --data-ascii "{}"
Error:   × error sending request to the runtime emulator
  ├─▶ error sending request for url (http://[::1]:9000/2015-03-31/functions/$LAMBDA_FUNCTION_NAME/invocations): error trying to connect: tcp connect error: Connection refused (os error 61)
  ├─▶ error trying to connect: tcp connect error: Connection refused (os error 61)
  ├─▶ tcp connect error: Connection refused (os error 61)
  ╰─▶ Connection refused (os error 61)

結果

無事に Rust 製の Lambda 関数をビルド・デプロイできた。

実行の頻度も時間も重くないので、特段気になるところではないが、一応 Billed Duration も Max Memory Used も大きく減った。

Go の Lambda 関数は 600ms, 35MB 程度だったが、Rust だと 400ms, 18MB 程度になっていた*2

以上。

作業リポジトリ: github.com

*1:ソースコードでの分岐箇所: https://github.com/cargo-lambda/cargo-lambda/blob/a342f0b8a7c34b17cee2f4fed3cc7d3ddbd810ab/crates/cargo-lambda-invoke/src/lib.rs#L156-L160

*2:Go は norpc オプションを有効にしてないなど最適化の余地が残ってるかもしれない

Rusqlite クレートを使って Rust で SQLite を利用する

概要

Rust で SQLite を使うためのラッパークレートである Rusqlite を使ってみる。

PythonSQLite バイナリを作成し、それを Rust で Rusqlite を使って読み込みクエリを実行する流れのサンプルコードを書いてみた。

github.com

Python で作成した SQLite バイナリを Rust で読み込んでクエリを実行する

ディレクトリ構成

以下のようなディレクトリ構成になってる想定でコードを書いていく。

.
├── python
│  └── python3.10
│     ├── data
│     │  └── users.sqlite
│     ├── poetry.lock
│     ├── pyproject.toml
│     ├── README.md
│     ├── src
│     │  ├── __init__.py
│     │  └── __main__.py
│     └── tasks.py
└── rust
   ├── Cargo.lock
   ├── Cargo.toml
   ├── Makefile.toml
   ├── README.md
   └── src
      └── main.rs

PythonSQLite のバイナリを作成する

使用するサードパーティライブラリはないので pyproject.toml は不要かもしれない。

  • pyproject.toml
[tool.poetry]
name = "python3-10"
version = "0.1.0"
description = ""
authors = ["Your Name <you@example.com>"]
packages = [{include = "src"}]

[tool.poetry.dependencies]
python = "~3.10"

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

以下のように、テーブル作成・値の挿入を行ったデータベースを保存する。

テスト代わりに Python 側でクエリを実行できるかを最後に確認している。

  • main.py
import sqlite3


CREATE_QUERY = """
    CREATE TABLE users (
        id INTEGER,
        age INTEGER NOT NULL,
        name TEXT NOT NULL,
        PRIMARY KEY (id)
);
"""

INSERT_QUERY = """
    INSERT INTO users
    VALUES
        (1, 20, 'Alice'),
        (2, 30, 'Bob'),
        (3, 40, 'Carol');
"""

SELECT_QUERY = """
    SELECT *
    FROM users;
"""

def main():
    path = "data/users.sqlite"

    with open(path, mode="w") as f:
        conn = sqlite3.connect(f.name)
        cursor = conn.cursor()
        cursor.execute(CREATE_QUERY)
        cursor.execute(INSERT_QUERY)
        conn.commit()
        conn.close()

    with open(path, mode="r") as f:
        conn = sqlite3.connect(f.name)
        cursor = conn.cursor()
        cursor.execute(SELECT_QUERY)
        print(cursor.fetchall())


if __name__ == "__main__":
    main()
  • 出力
[(1, 20, 'Alice'), (2, 30, 'Bob'), (3, 40, 'Carol')]

参考: Python: テストで SQLite3 のインメモリデータベースを使うときの問題点と解決策 - CUBE SUGAR CONTAINER

Rust で SQLite バイナリを読み込んでクエリを実行する

Cargo.toml に rusqlite を追加する。

features には bundledbackup を指定する。

前者はユーザーシステムの SQLite への自動リンクを可能にし、後者は SQLite バイナリからデータベースを復元する際に必要となる。

  • Cargo.toml
[package]
name = "rust"
version = "0.1.0"
edition = "2021"

[dependencies]
rusqlite = { version = "0.29.0", features = ["bundled", "backup"] }

Connection::open_in_memory() でインメモリな SQLite データベースを構築し、それに対して restore() することで復元する。

その後はクエリを実行し、Rust で定義した構造体にクエリの実行結果をマップする。

出力内容が Python のものと一致していることが確認できる。

  • main.rs
use rusqlite::{Connection, DatabaseName};

#[derive(Debug)]
#[allow(dead_code)]
struct User {
    id: i32,
    age: i32,
    name: String,
}

fn main() {
    let path = "python/python3.10/data/users.sqlite";
    let path = format!("{}/../{}", env!("CARGO_MANIFEST_DIR"), path);
    let mut conn = Connection::open_in_memory().unwrap();
    conn.restore(DatabaseName::Main, &path, Some(|_| {})).unwrap();

    let mut stmt = conn.prepare("SELECT id, age, name FROM users").unwrap();
    let user_iter = stmt.query_map([], |row| {
        Ok(User {
            id: row.get(0).unwrap(),
            age: row.get(1).unwrap(),
            name: row.get(2).unwrap(),
        })
    }).unwrap();

    for user in user_iter {
        println!("Found user {:?}", user.unwrap());
    }
}
  • 出力
Found user User { id: 1, age: 20, name: "Alice" }
Found user User { id: 2, age: 30, name: "Bob" }
Found user User { id: 3, age: 40, name: "Carol" }

参考: GitHub - rusqlite/rusqlite: Ergonomic bindings to SQLite for Rust

まとめ

Rustqlite を使えば Rust でも SQLite を気軽に利用できそう。

Rust の Result と Option のメソッドまとめ

項目の末尾の * は Result あるいは Option にしかないメソッドであることを表す。

概要

Rust には様々な標準ライブラリが用意されています。

その中には Result 型や Option 型といった便利な型があります。

これらにはいくつかのメソッドが用意されており、エラーや None の値を簡潔かつ適切に処理することができます。

この記事では Result 型と Option 型のいくつかのメソッドについてまとめて、列挙していきます。

std::Result

doc.rust-lang.org

Rust には Result 型と言われるものがあり、これは関数が成功するか失敗するかの結果を表現する列挙型です。

一般的には、成功時には Ok 値を、失敗時には Err 値を返します。

Result 型は、 Rust のエラーハンドリングにおいて非常に重要な役割を担っています。

pub enum Result<T, E> {
    Ok(T),
    Err(E),
}

map

  • Ok の場合には関数を適用して Err の場合にはそのままにして Result を返す
pub fn map<U, F>(self, op: F) -> Result<U, E>
where
    F: FnOnce(T) -> U,

Example

let line = "1\n2\n3\n4\n";

for num in line.lines() {
    match num.parse::<i32>().map(|i| i * 2) {
        Ok(n) => println!("{n}"),
        Err(..) => {}
    }
}

map_err*

  • Ok の場合にはそのままにして Err の場合には関数を適用して Result を返す
pub fn map_err<F, O>(self, op: O) -> Result<T, F>
where
    O: FnOnce(E) -> F,

Example

fn stringify(x: u32) -> String { format!("error code: {x}") }

let x: Result<u32, u32> = Ok(2);
assert_eq!(x.map_err(stringify), Ok(2));

let x: Result<u32, u32> = Err(13);
assert_eq!(x.map_err(stringify), Err("error code: 13".to_string()));

map_or

  • Ok の場合には関数を適用して Err の場合にはデフォルト値を返す
pub fn map_or<U, F>(self, default: U, f: F) -> U
where
    F: FnOnce(T) -> U,

Example

let x: Result<_, &str> = Ok("foo");
assert_eq!(x.map_or(42, |v| v.len()), 3);

let x: Result<&str, _> = Err("bar");
assert_eq!(x.map_or(42, |v| v.len()), 42);

map_or_else

  • Ok の場合には関数を適用して Err の場合にはフォールバック関数を適用する
pub fn map_or_else<U, D, F>(self, default: D, f: F) -> U
where
    D: FnOnce(E) -> U,
    F: FnOnce(T) -> U,

Example

let k = 21;

let x : Result<_, &str> = Ok("foo");
assert_eq!(x.map_or_else(|e| k * 2, |v| v.len()), 3);

let x : Result<&str, _> = Err("bar");
assert_eq!(x.map_or_else(|e| k * 2, |v| v.len()), 42);

unwrap

  • Ok の場合には値をそのまま返して Err の場合にはパニックを起こす
pub fn unwrap(self) -> T
where
    E: Debug,

Example

let x: Result<u32, &str> = Ok(2);
assert_eq!(x.unwrap(), 2);

// This example panics
let x: Result<u32, &str> = Err("emergency failure");
x.unwrap(); // panics with `emergency failure`

unwrap_err*

  • Ok の場合にはパニックを起こして Err の場合には値をそのまま返す
pub fn unwrap_err(self) -> E
where
    T: Debug,

Example

// This example panics
let x: Result<u32, &str> = Ok(2);
x.unwrap_err(); // panics with `2`

let x: Result<u32, &str> = Err("emergency failure");
assert_eq!(x.unwrap_err(), "emergency failure");

unwrap_or

  • Ok の場合には値をそのまま返して Err の場合には引数の値をデフォルト値として返す
pub fn unwrap_or(self, default: T) -> T

Example

let default = 2;
let x: Result<u32, &str> = Ok(9);
assert_eq!(x.unwrap_or(default), 9);

let x: Result<u32, &str> = Err("error");
assert_eq!(x.unwrap_or(default), default);

unwrap_or_else

  • Ok の場合には値をそのまま返して Err の場合には引数で受けたクロージャの返り値を返す
pub fn unwrap_or_else<F>(self, op: F) -> T
where
    F: FnOnce(E) -> T,

Example

fn count(x: &str) -> usize { x.len() }

assert_eq!(Ok(2).unwrap_or_else(count), 2);
assert_eq!(Err("foo").unwrap_or_else(count), 3);

unwrap_or_default

  • Ok の場合には値をそのまま返して Err の場合には Ok の型のデフォルト値を返す
pub fn unwrap_or_default(self) -> T
where
    T: Default,

Example

let good_year_from_input = "1909";
let bad_year_from_input = "190blarg";
let good_year = good_year_from_input.parse().unwrap_or_default();
let bad_year = bad_year_from_input.parse().unwrap_or_default();

assert_eq!(1909, good_year);
assert_eq!(0, bad_year);

and_then

  • Ok の場合には関数を呼び出して Err の場合には値をそのままにして Result を返す
  • Ok 型が返ってくるmap と異なり、関数の返り値も Result になっている
pub fn and_then<U, F>(self, op: F) -> Result<U, E>
where
    F: FnOnce(T) -> Result<U, E>,

Example 1

fn sq_then_to_string(x: u32) -> Result<String, &'static str> {
    x.checked_mul(x).map(|sq| sq.to_string()).ok_or("overflowed")
}

assert_eq!(Ok(2).and_then(sq_then_to_string), Ok(4.to_string()));
assert_eq!(Ok(1_000_000).and_then(sq_then_to_string), Err("overflowed"));
assert_eq!(Err("not a number").and_then(sq_then_to_string), Err("not a number"));

Example2: Err を返すかもしれないメソッドチェーンの操作内で用いられる

use std::{io::ErrorKind, path::Path};

// Note: on Windows "/" maps to "C:\"
let root_modified_time = Path::new("/").metadata().and_then(|md| md.modified());
assert!(root_modified_time.is_ok());

let should_fail = Path::new("/bad/path").metadata().and_then(|md| md.modified());
assert!(should_fail.is_err());
assert_eq!(should_fail.unwrap_err().kind(), ErrorKind::NotFound);

or_else

  • Ok の場合には値をそのままにして Err の場合には関数を呼び出して Result を返す
  • Err 型が返ってくる map_err と異なり、関数の返り値も Result になっている
pub fn or_else<F, O>(self, op: O) -> Result<T, F>
where
    O: FnOnce(E) -> Result<T, F>,

Example

fn sq(x: u32) -> Result<u32, u32> { Ok(x * x) }
fn err(x: u32) -> Result<u32, u32> { Err(x) }

assert_eq!(Ok(2).or_else(sq).or_else(sq), Ok(2));
assert_eq!(Ok(2).or_else(err).or_else(sq), Ok(2));
assert_eq!(Err(3).or_else(sq).or_else(err), Ok(9));
assert_eq!(Err(3).or_else(err).or_else(err), Err(3));

ok

  • Result<T, E>Option<T> に変換する
  • Err は潰れて None になる
pub fn ok(self) -> Option<T>

Example

let x: Result<u32, &str> = Ok(2);
assert_eq!(x.ok(), Some(2));

let x: Result<u32, &str> = Err("Nothing here");
assert_eq!(x.ok(), None);

err

  • Result<T, E>Option<E> に変換する
  • Ok は潰れて None になる
pub fn err(self) -> Option<E>

Example

let x: Result<u32, &str> = Ok(2);
assert_eq!(x.err(), None);

let x: Result<u32, &str> = Err("Nothing here");
assert_eq!(x.err(), Some("Nothing here"));

std::Option

また、 Rust には Option 型と言われるものがあり、これは値が存在するかどうかを表現するための列挙型です。

Some 値を持つ場合は Some(v) という値を、値が存在しない場合は None という値を持ちます。

Option 型は、Rust においてエラーハンドリングに使われるだけでなく、 Rust のパターンマッチングにおいても頻繁に使われます。

doc.rust-lang.org

pub enum Option<T> {
    None,
    Some(T),
}

map

  • Some の場合には関数を適用してNone の場合にはそのままにして Option を返す
pub fn map<U, F>(self, f: F) -> Option<U>
where
    F: FnOnce(T) -> U,

Example

/*
   Converts an Option<String> into an Option<usize>, consuming the original:
*/
let maybe_some_string = Some(String::from("Hello, World!"));
// `Option::map` takes self *by value*, consuming `maybe_some_string`
let maybe_some_len = maybe_some_string.map(|s| s.len());

assert_eq!(maybe_some_len, Some(13));

map_or

  • Some の場合には関数を適用してNone の場合にはデフォルト値を返す
pub fn map_or<U, F>(self, default: U, f: F) -> U
where
    F: FnOnce(T) -> U,

Example

let x = Some("foo");
assert_eq!(x.map_or(42, |v| v.len()), 3);

let x: Option<&str> = None;
assert_eq!(x.map_or(42, |v| v.len()), 42);

map_or_else

  • Some の場合には関数を適用してNone の場合には別のデフォルト関数を適用する
pub fn map_or_else<U, D, F>(self, default: D, f: F) -> U
where
    D: FnOnce() -> U,
    F: FnOnce(T) -> U,

Example

let k = 21;

let x = Some("foo");
assert_eq!(x.map_or_else(|| 2 * k, |v| v.len()), 3);

let x: Option<&str> = None;
assert_eq!(x.map_or_else(|| 2 * k, |v| v.len()), 42);

unwrap

  • Some の場合には値をそのまま返して None の場合にはパニックを起こす
pub fn unwrap(self) -> T

Example

let x = Some("air");
assert_eq!(x.unwrap(), "air");

// This example panics
let x: Option<&str> = None;
assert_eq!(x.unwrap(), "air"); // fails

unwrap_or

  • Some の場合には値をそのまま返して None の場合には引数の値をデフォルト値として返す
pub fn unwrap_or(self, default: T) -> T

Example

assert_eq!(Some("car").unwrap_or("bike"), "car");
assert_eq!(None.unwrap_or("bike"), "bike");

unwrap_or_else

  • Some の場合には値をそのまま返して None の場合には引数で受けたクロージャの返り値を返す
pub fn unwrap_or_else<F>(self, f: F) -> T
where
    F: FnOnce() -> T,

Example

let k = 10;
assert_eq!(Some(4).unwrap_or_else(|| 2 * k), 4);
assert_eq!(None.unwrap_or_else(|| 2 * k), 20);

unwrap_or_default

  • Some の場合には値をそのまま返して None の場合には Some の型のデフォルト値を返す
pub fn unwrap_or_default(self) -> T
where
    T: Default,

Example

let good_year_from_input = "1909";
let bad_year_from_input = "190blarg";
let good_year = good_year_from_input.parse().ok().unwrap_or_default();
let bad_year = bad_year_from_input.parse().ok().unwrap_or_default();

assert_eq!(1909, good_year);
assert_eq!(0, bad_year);

and_then

  • Some の場合には関数を呼び出して None の場合には値をそのままにして Option を返す
  • Some 型が返ってくるmap と異なり、関数の返り値も Option になっている
pub fn and_then<U, F>(self, f: F) -> Option<U>
where
    F: FnOnce(T) -> Option<U>,

Example1

fn sq_then_to_string(x: u32) -> Option<String> {
    x.checked_mul(x).map(|sq| sq.to_string())
}

assert_eq!(Some(2).and_then(sq_then_to_string), Some(4.to_string()));
assert_eq!(Some(1_000_000).and_then(sq_then_to_string), None); // overflowed!
assert_eq!(None.and_then(sq_then_to_string), None);

Example2: Err を返すかもしれないメソッドチェーンの操作内で用いられる

let arr_2d = [["A0", "A1"], ["B0", "B1"]];

let item_0_1 = arr_2d.get(0).and_then(|row| row.get(1));
assert_eq!(item_0_1, Some(&"A1"));

let item_2_0 = arr_2d.get(2).and_then(|row| row.get(0));
assert_eq!(item_2_0, None);

or_else

  • Some の場合には値をそのままにして None の場合には関数を呼び出して Option を返す
pub fn or_else<F>(self, f: F) -> Option<T>
where
    F: FnOnce() -> Option<T>,

Example

fn nobody() -> Option<&'static str> { None }
fn vikings() -> Option<&'static str> { Some("vikings") }

assert_eq!(Some("barbarians").or_else(vikings), Some("barbarians"));
assert_eq!(None.or_else(vikings), Some("vikings"));
assert_eq!(None.or_else(nobody), None);

ok_or*

  • Some(v)Ok(v) に、 NoneErr(err)マッピングして Option<T>Result<T, E> に変換する
pub fn ok_or<E>(self, err: E) -> Result<T, E>

Example

let x = Some("foo");
assert_eq!(x.ok_or(0), Ok("foo"));

let x: Option<&str> = None;
assert_eq!(x.ok_or(0), Err(0));

ok_or_else*

  • Some(v)Ok(v) に、 NoneErr(err())マッピングして Option<T>Result<T, E> に変換する
pub fn ok_or_else<E, F>(self, err: F) -> Result<T, E>
where
    F: FnOnce() -> E,

Example

let x = Some("foo");
assert_eq!(x.ok_or_else(|| 0), Ok("foo"));

let x: Option<&str> = None;
assert_eq!(x.ok_or_else(|| 0), Err(0));

Tips

  • ResultOptionmap_or_else で None と Some を返すようにして変換できる
  • OptionResultok_or あるいは ok_or_else で変換できる

リモートモブプロのための mob コマンドに送った機能追加の Pull Request がリリースされた

概要

リモートモブプロ用ツール mob に送った PR がマージされ、無事リリースされたので、それについて書く。

mob コマンドとは

オンサイトでのモブプログラミングでは同じ端末を共有してタイピストを交代しながら開発を行うが、これをリモートでする際には git ブランチをスムーズに受け渡しながらタイピストを交代する必要がある。そのためのツールが mob コマンドである。

GitHub - remotemobprogramming/mob: Tool for smooth git handover.

リリースされるまで

Issue を立てる

mob コマンドでは mob start でモブのための一時的なブランチを作成し、リモートにプッシュする。

しかし、コードレポジトリでプッシュ起動によって CI が実行される設定の場合、新たにモブブランチを作成するたびに CI が走ってしまう(作業途中にコミットを行う mob next ではデフォルトでコミットメッセージが mob next [ci-skip] [ci skip] [skip ci] になるので問題ない)。

そこで mob start でリモートにモブブランチが新規作成される時に [ci-skip] をコミットメッセージに含んだ空コミットをすることで CI を動作させないようにする実装を提案した。

github.com

実装する

Issue で一通り実装の方針をディスカッションし(時差の問題で1日1メッセージずつのやりとりだった)、方針が固まった後に実装を開始した。この Issue でのディスカッションを通して、メンテナからレポジトリの管理方針が共有されたり、自分の想定してなかった影響範囲に対するコメントを貰ったりできて、実装に入る前に議論することの重要性を改めて感じた。

ちなみに mob コマンドは Golang で書かれていて、その中で必要な git コマンドを叩いているのだが、モブブランチでの作業を終了し、ベースブランチに変更内容を反映する際のあるオプションの実装方法が興味深かった。

mob done --squash-wip というコマンドでは mob next で生成されたコミットは squash し、それ以外の手動で行われたコミットは squash せずにそのまま利用するという挙動なのだが、この実装方法が git rebase -i で立ち上がる環境変数 GIT_EDITOR を書き換えることで、特定のコミットを squash するという離れ業だった。このへんはだいぶ特殊なやり方のように思えたが、こういう方法あるんだと勉強になった。

setEnvGitEditor(
    mobExecutable()+" squash-wip --git-editor",
    mobExecutable()+" squash-wip --git-sequence-editor",
)
say.Info("rewriting history of '" + currentWipBranch.String() + "': squashing wip commits while keeping manual commits.")
git("rebase", "--interactive", "--keep-empty", mergeBase)
case "sw", "squash-wip":
    if len(parameter) > 1 && parameter[0] == "--git-editor" {
        squashWipGitEditor(parameter[1], configuration)
    } else if len(parameter) > 1 && parameter[0] == "--git-sequence-editor" {
        squashWipGitSequenceEditor(parameter[1], configuration)
    }

レビューを受ける

Issue で大方実装の方針が決まっていたので Pull Request でのレビューはあまり紛糾することはなかった。

ただ、Issue でのやり取りが長くて当初合意していたことを失念し、テストの修正が面倒だったこともあって自分の実装をオプトインのオプションを付けたら、誰もモブセッションで CI を走らせたくないし、不要なコード増えるからやめろとちゃんと指摘された。

github.com

マージ、そしてリリースされる

Pull Request でのレビューは早々に終わり、 依頼されたので拙い英語で CHANGELOG も書いた。

マージされた翌日にはリリースされた。

github.com

感想

自チームでも使用している、スターが 1 万超えの OSSリポジトリに PR がマージされ、リリースされる経験が初めてで嬉しかったので記事として残すことにした。

メンテナがおそらくヨーロッパ地域に在住している方のためか、朝に自分がコメント → 深夜にメンテナがコメントをするという、まるで文通のような Issue のやり取りは少し歯がゆかった記憶がある。

それでも、ディスカッションやレビューを通して、そのリポジトリやメンテナの思想に触れることが、自分自身の勉強にもなって、なかなか楽しい期間だった。