nikkie-ftnextの日記

イベントレポートや読書メモを発信

OpenAIのChat completions APIをstreamで使う(Pythonクライアントでstream=Trueを指定)

はじめに

今晩は朝までラーニング! nikkieです。

ChatGPTが流れるように出力する仕組みに迫る素振りです。

目次

OpenAI Cookbook「How to stream completions」

OpenAIのGPTをはじめとするLLM(大規模言語モデル)は、プロンプトに続く文章を1トークンずつ生成しています1

OpenAIのAPIを普通に使うと、すべてのトークンが生成されたレスポンスが返ります2
すべてのトークンが揃うまでレスポンスが返らないので、待ちがあります3

一方、Web UIからChatGPTを使うと、テキストが流れて表示されます。
おそらくここに使っていると思われるのが、stream呼び出しとでも呼ぶべき機能。
生成したトークンを1トークンずつ返させることができます。

上記のCookbookには、streamでレスポンスを生成させるやり方が載っています。
こちらに沿って素振りしました。

なお、streamを指定することで生成が完全に終わるまでユーザを待たせずに出力を始められるのですが、コンテンツのモデレーション4は難しくなるとのことです(「Downsides」参照)

動作環境

uv 0.4.27 で inline script metadata (PEP 723)5 を使っています

環境変数OPENAI_API_KEYを指定しています

streamを指定しない場合

from openai import OpenAI

prompt = "Count to 10, with a comma between each number and no newlines. E.g., 1, 2, 3, ..."

client = OpenAI()
response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": prompt}],
    temperature=0,
)
% PYTHONINSPECT=1 uv run openai_stream.py
>>> type(response)
<class 'openai.types.chat.chat_completion.ChatCompletion'>
>>> response.choices[0].message.content
'1, 2, 3, 4, 5, 6, 7, 8, 9, 10'

生成されたテキストが返ってきていますね。

streamを指定する場合

from openai import OpenAI

prompt = "Count to 10, with a comma between each number and no newlines. E.g., 1, 2, 3, ..."

client = OpenAI()
stream_response = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": prompt}],
    temperature=0,
    stream=True,
)
for chunk in stream_response:
    print(chunk)
    print(chunk.choices[0].delta.content)
    print("*"* 20)
% PYTHONINSPECT=1 uv run openai_stream.py
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content='', function_call=None, refusal=None, role='assistant', tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)

********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content='1', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
1
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content=',', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
,
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content=' ', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
 
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content='2', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
2
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content=',', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
,
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content=' ', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
 
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content='3', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
3
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content=',', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
,
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content=' ', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
 
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content='4', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
4
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content=',', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
,
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content=' ', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
 
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content='5', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
5
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content=',', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
,
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content=' ', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
 
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content='6', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
6
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content=',', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
,
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content=' ', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
 
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content='7', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
7
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content=',', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
,
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content=' ', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
 
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content='8', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
8
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content=',', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
,
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content=' ', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
 
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content='9', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
9
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content=',', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
,
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content=' ', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
 
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content='10', function_call=None, refusal=None, role=None, tool_calls=None), finish_reason=None, index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
10
********************
ChatCompletionChunk(id='chatcmpl-AWjPGQ6exHoP0NWXH33IkBoOCwPeo', choices=[Choice(delta=ChoiceDelta(content=None, function_call=None, refusal=None, role=None, tool_calls=None), finish_reason='stop', index=0, logprobs=None)], created=1732364402, model='gpt-4o-mini-2024-07-18', object='chat.completion.chunk', service_tier=None, system_fingerprint='fp_0705bf87c0', usage=None)
None
********************

chunk.choices[0].delta.contentにご注目。
空文字列(role=assistant指定) -> 1 -> , -> 半角スペース -> 2 -> ... と、1トークンずつ返されています。
最後はcontentがNoneですね(finish_reason='stop'となっているので、これが制御に使えそう)

stream=Trueを指定すると、client.chat.completions.createから返るオブジェクトが変わっています。

>>> type(stream_response)
<class 'openai.Stream'>

まとめ:OpenAIのChat completionsでstream指定の有無の比較

********** stream=False response **********
1, 2, 3, 4, 5, 6, 7, 8, 9, 10

********** stream=True response **********

1
,
 
2
,
 
3
,
 
4
,
 
5
,
 
6
,
 
7
,
 
8
,
 
9
,
 
10
None

終わりに

OpenAIのChat completions APIのstreamの素振りでした。

  • デフォルトではstream=False
    • response.choices[0].message.contentに生成されたテキスト(完全版)
  • stream=Trueを指定する
    • 返ったレスポンスはforで回す(Iterableということ)
    • chunk.choices[0].delta.contentに生成された1トーク
    • roleやfinish_reasonを見て制御できそう(宿題事項)

今回のCookbookにある6のですが、server-sent eventsというのがキーワードっぽいです。

番外編:asyncバージョン

出力はasyncでない場合と一致していました


  1. 続く箇条書きはgreedy decodingを念頭に置いています。他にもbeam searchなどがありますが、分かりやすさのために採用しました
  2. Cookbook「By default, when you request a completion from the OpenAI, the entire completion is generated before being sent back in a single response.
  3. Cookbook「If you're generating long completions, waiting for the response can take many seconds.
  4. OpenAIはModeration APIを無料で提供しています
  5. 手前味噌ですが
  6. This will return an object that streams back the response as data-only server-sent events.

clap初心者、疑問を調べる (1)#[clap]と#[arg]の違いは? (2)default_valueとdefault_value_tの違いは?

はじめに

俺は、負けた...(今週のアニメアオのハコ) nikkieです。

今回はここまでのclapの素振りの番外編、落ち穂拾いしていきます。

目次

あの日argparseで書いたプログラムのRust版を僕はまだ知らない。

第1話でclapの位置引数を完全に理解し、

第2話でオプション引数を、第3話でサブコマンドを完全に理解しました。

現時点の結論:ドキュメントのExampleに合わせよう

  • #[clap]よりも#[arg]を使う
    • #[clap]はdeprecated
  • デフォルト値を指定する場合、default_value = ...でもdefault_value_t = ...でもどちらでもよい
    • IMO:ドキュメントにならってdefault_value_tを使いたい(パース後の文字列でない値で指定できる)

https://docs.rs/clap/latest/clap/index.html#example

#[derive(Parser, Debug)]
struct Args {
    #[arg(short, long)]
    name: String,

    #[arg(short, long, default_value_t = 1)]
    count: u8,
}

疑問1:オプション引数は#[clap]でも#[arg]でもできる?

% cargo run --quiet -- -s 天才 -n 5
天才天才天才天才天才

素振りしていて気づいたのは、#[clap]でも#[arg]でもオプション引数が実装できる、ということ。

#[derive(Parser)]
struct Cli {
    #[clap(short, long)]
    string: String,
    #[clap(short, long, default_value = "3")]
    number: usize,
}
#[derive(Parser)]
struct Cli {
    #[arg(short, long)]
    string: String,
    #[arg(short, long, default_value = "3")]
    number: usize,
}

結論としては、好ましいのは#[arg]です。
#[clap]はdeprecatedとなっています。

$ cargo check --features clap/deprecated

warning: use of deprecated function `<Cli as clap::Args>::augment_args::old_attribute`: Attribute `#[clap(...)]` has been deprecated in favor of `#[arg(...)]`
 --> src/main.rs:5:7
  |
5 |     #[clap(short, long)]
  |       ^^^^
  |
  = note: `#[warn(deprecated)]` on by default

疑問2:default_valuedefault_value_tは何が違う?

引数のデフォルト値について

$ cargo run --quiet -- -s シオン
シオンシオンシオン

指定方法が2つあることに気づきました

  • default_value文字列を指定(パースされる)
  • default_value_t:パースされた後の型で値を指定(文字列でなくてよい
#[derive(Parser)]
struct Cli {
    #[arg(short, long)]
    string: String,
    #[arg(short, long, default_value = "3")]
    number: usize,
}
#[derive(Parser)]
struct Cli {
    #[arg(short, long)]
    string: String,
    #[arg(short, long, default_value_t = 3)]
    number: usize,
}

ドンピシャな回答がありました。

#[clap(default_value)] used to mix these concepts and would set your default to T::default.
We deprecated this and instead encourage #[clap(default_value_t)].
This doesn't mean default_value = ... is deprecated, just without a = is.

  • #[clap(default_value)]が非推奨。代わりに#[clap(default_value_t)]がオススメ
  • ただし、default_value = ...が非推奨ではない(default_value = ...default_value_t = ...も両方使える認識)

上記discussionで案内されていたドキュメントより
https://docs.rs/clap/latest/clap/_derive/index.html#arg-attributes

  • default_value = <str>
    • 文字列しか渡せない
    • default_value [= <str>]となっていないので、値を指定しないdefault_valueが非推奨)
  • default_value_t [= <expr>]
    • 式を渡さなくてもよい!

終わりに

clapの素振りから生まれた2つの疑問を解決しました。
cargo checkで非推奨な書き方が分かるの、すごいですね

アップデートしたソースコードです。

きっかけツイート([clap]やは「は」のtypo、default_value_tが「好ましい」は誤読)

あの日argparseで書いたプログラムのRust版を僕はまだ知らない。第2話 ゆうしゃオプション・第3話 サブコマンドを探そうの会

はじめに

とぅーす!」(かわいい) nikkieです。

Pythonのargparseで書いたCLIをRustのCLAPで再実装してみようシリーズ(※小さな素振り群)の続編、2話連続放送です!
第1話はこちら

CLAPの素振り その1:オプション引数

今手元にあるのは位置引数で指定するバージョン

$ cargo run --quiet シオン 3
シオンシオンシオン
#[derive(Parser)]
struct Cli {
    string: String,
    #[clap(default_value = "3")]
    number: usize,
}

これをオプション引数で渡せるように変更します。

$ cargo run --quiet -- -s シオン -n 3
シオンシオンシオン

そのやり方はExampleにありました。
https://docs.rs/clap/latest/clap/#example
[#[arg(short, long)]]1

#[derive(Parser)]
struct Cli {
    #[arg(short)]
    string: String,
    #[arg(short, default_value = "3")]
    number: usize,
}

[#[arg(short, long)]]との違いの1つはヘルプメッセージです(longを指定してよいと思います)

Options:
  -s <STRING>      
  -n <NUMBER>      [default: 3]
Options:
  -s, --string <STRING>  
  -n, --number <NUMBER>  [default: 3]

CLAPの素振り その2:サブコマンド

参考:Pythonのargparseの例はこちら

位置引数サブコマンドとオプション引数サブコマンドを実装してみます。

$ cargo run --quiet -- position 天才 5
天才天才天才天才天才
$ cargo run --quiet -- option -s おはぎ -n 2
おはぎおはぎ

参考にしたのは、cookbookのgitの例。

use clap::{Parser, Subcommand};

#[derive(Parser)]
struct Cli {
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    Position {
        string: String,
        #[arg(default_value = "3")]
        number: usize,
    },
    Option {
        #[arg(short, long)]
        string: String,
        #[arg(short, long, default_value = "3")]
        number: usize,
    },
}

コマンド自体の実装はこのように続きます

fn main() {
    let args = Cli::parse();
    match args.command {
        Commands::Position { string, number } => {
            println!("{}", string.repeat(number));
        }
        Commands::Option { string, number } => {
            println!("{}", string.repeat(number));
        }
    }
}

enumのコマンドに応じてパターンマッチ!
enumだから、args.commandCommands::PositionまたはCommands::Optionなわけですね。
サブコマンドまでできちゃった!

終わりに

RustのCLAPで

  • オプション引数も持つCLIが作れました!
    • [#arg(short, long)]
  • サブコマンドも実装できました!
    • enumを定義し、#[derive(Parser)]の付いたStructでそのenumを指定する

ここまでの3話で、argparseで書いているCLIの8割くらいは、RustのCLAPでも今の私は書けるのではという感触です(※まさに馬鹿の山です)。
コマンドライン引数のパーサはどう書けばいいか分かってきたので、内部の処理に移ったらアイデアCLIという形にできて楽しそうです。

今回のソースコードはこちらです(引き続きテストもあります!)


  1. デフォルト値は#[clap(default_value = "3")]としていました。#[clap(...)]#[arg(...)]は交換可能に思われます(さらに詳しくは宿題事項)

あの日argparseで書いたプログラムのRust版を僕はまだ知らない。第1話 超位置引数パーサーズ

はじめに

実はいくおな、nikkieです。

PyCon mini 東海 2024をきっかけにRustでCLIを作ることに興味を持ちました。
これまでのアウトプットと絡めて小さく素振りしていきます。

目次

「pytestでRust製CLIをe2eテストしてみよう」

attakeiさんによるトーク
https://tokai.pycon.jp/2024/#event-talk-2

自作されたageを題材に、pytestでe2eを書いているというお話でした1
聞く中で私はageの実装にも興味を持ちます。
CLAPなるものを使っているようでした

また、過去に『Rustの練習帳』で、出力するだけと簡単なコマンドのe2eを書いたことも思い出しました。
assert_cmdというものを使います。

CLAPの素振り:まずは位置引数だけを扱ってみよう!

RustでCLIとそのテストを実装してみたくなった私が思い付いたのは、過去に書いたプログラムの書き換えです。
私はCLIに思い入れがあり、Pythonのargparseで色々と書いてきました2

ハードルを下げるためにとにかく小さくしたかったので(だってRustは難しいじゃないですか!)、最初は位置引数だけ扱ってみることにしました。
これだけでも私には冒険です!

argparseバージョンの記事はこちら

$ python repeat.py シオン 3
シオンシオンシオン

これをRustでCLAPを使って書いていきます。
できあがったのがこちら

$ cargo run --quiet シオン 3
シオンシオンシオン

CLAPで位置引数を扱う

Command Line Argument Parser

CLAPのドキュメントから、「Command Line Applications in Rust」を見つけます。

grepを再実装していく(その名もgrrs)のですが、位置引数の扱いは1.2にありました。
https://rust-cli.github.io/book/tutorial/cli-args.html#parsing-cli-arguments-with-clap

CLAPのドキュメントのExampleも合わせて参考にします。
https://docs.rs/clap/latest/clap/#example

Cargo.toml(該当箇所のみ)

[dependencies]
clap = { version = "4", features = ["derive"] }

src/main.rs3

use clap::Parser;

#[derive(Parser)]
struct Cli {
    string: String,
    number: usize,
}

fn main() {
    let args = Cli::parse();
    println!("{}", args.string.repeat(args.number));
}

derive featureなるものがまるで魔法ですね。
これでargparseと同じ振る舞いのCLIが実装できました!

追加の学び

位置引数にデフォルト値を与える

これはめちゃめちゃ簡単でした(魔法だ...)

#[derive(Parser)]
struct Cli {
    string: String,
+    #[clap(default_value = "3")]
    number: usize,
}

デフォルト値を与えたことで、argparse版同様に省略できる位置引数となりました

$ cargo run --quiet シオン  
シオンシオンシオン

assert_cmdでコマンドのテスト

tests/cli.rs

use assert_cmd::Command;

#[test]
fn 文字列と回数を指定して繰り返す() {
    let mut cmd = Command::cargo_bin("repeat").unwrap();
    let assert = cmd.arg("大好き").arg("5").assert();
    assert.success().stdout("大好き大好き大好き大好き大好き\n");
}

今回のCLIは出力するだけなので、assert_cmdを使ってテストも書けています。
正常終了して、かつ、標準出力がこれこれと検証していますね。

$ cargo test

running 2 tests
test 文字列だけ指定した場合は3回繰り返す ... ok
test 文字列と回数を指定して繰り返す ... ok

終わりに

PyCon mini 東海でぐぐっと上がったRust製CLIへのモチベーションから、CLAPを素振りしました。

  • 位置引数だけのCLIが作れました!
    • derive feature、すっげ〜(どういう仕組みなの!?)
  • assert_cmdも使ってテストも合わせて書けています
    • PythonCLIのテストを書けるようになるまでに、私はだいぶ時間がかかりました

argparseの経験や『Rustの練習帳』の素振りの経験が重なって、今回のトピックは小さな背伸びという感じでした。
引き続きCLAPを素振りしていくぞ!(Next...

今回のソースコードはこちらです


  1. pytestについても多くを学べる発表でした
  2. argparseの技術同人誌も書きました
  3. main.rsのmain関数に書くことを思い出しました

pandasのDataFrameのlocインデクサは行、列の順で絞り込む(例:df.loc[df["b"]>12, "a"])

はじめに

いくおです。 nikkieです。

Today(※最近) I learned です。

目次

動作環境

% uv version
uv 0.4.27 (Homebrew 2024-10-25)
% uv run --python 3.12 --with pandas python
>>> import pandas as pd
>>> pd.__version__
'2.2.3'

[]を2回使って行と列を指定するも、そこに代入できていない

DataFrameを作ります

>>> df = pd.DataFrame({"a": [11, 21, 31], "b": [12, 22, 32], "c": [13, 23, 33]})
>>> df
    a   b   c
0  11  12  13
1  21  22  23
2  31  32  33

b列が12より大きい行(すなわち、インデックスが1と2の行)を取り出すと

>>> df[df["b"]>12]
    a   b   c
1  21  22  23
2  31  32  33

そのa列は

>>> df[df["b"]>12]["a"]
1    21
2    31
Name: a, dtype: int64

以上から、b列が12より大きい行のa列の値を書き換えようとすると

>>> df[df["b"]>12]["a"] = 99
<stdin>:1: SettingWithCopyWarning:
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy

例外が送出されたわけではないですが、dfは変わっていません(dfのコピーでだけa列の値が変わったのかな?)

>>> df
    a   b   c
0  11  12  13
1  21  22  23
2  31  32  33

locを使ってアクセス・代入してみる

警告で案内された.loc[row_indexer,col_indexer]を試します1
今回理解したのは、.loc[行を絞る,列を絞る] ということです。

b列が12より大きい行

>>> df.loc[df["b"]>12]
    a   b   c
1  21  22  23
2  31  32  33

b列が12より大きい行のa列

>>> df.loc[df["b"]>12, "a"]
1    21
2    31
Name: a, dtype: int64

書き換えられます!!

>>> df.loc[df["b"]>12, "a"] = 99
>>> df
    a   b   c
0  11  12  13
1  99  22  23
2  99  32  33

『pandasクックブック』にあたる

古い本ですが、手元の『pandasクックブック』よりdf.loc[rows, columns]という書式について

カンマの左側の選択は、行インデックスによる行選択、カンマの右側は常にカラムインデックスによるカラム選択だ。(p.79)

またクックブックでは、

  • df[]をインデックス演算子
  • df.loc[].locインデクサ

と呼んでいました (p.73)

終わりに

pandasのDataFrameでlocインデクサは行->列の順で絞れる(df.loc[rows, columns])ことを理解しました。
今回の例では、df["b"]>12行を絞り、それらの列を"a"で指定しました。
インデックス演算子で指定して値を代入したところ元が書き換わりませんでしたが、locインデクサを使うと書き換えられました!

pandasを知ってからlocインデクサは自分のものにできていない感じでしたが、今回で完全に理解しました!

pandasで空のDataFrameを作る(一番単純なテストケースに使いたく)

はじめに

アオのハコのリアタイは、劇薬。nikkieです。

Today I learnedです

目次

DataFrameをcopyしてから加工する関数、一番単純な入力は空のDataFrameでは?

先日のPyCon mini 東海ではpytestでテスト駆動開発のワークショップで登壇しました。
今回の関数を見た時に、その経験からふと思ったのです。

この記事で取り上げる関数は、pandasのDataFrameを受け取り、それをcopyしてから加工して、新たなDataFrameを返しています。
つまり、渡したDataFrameにいくつかカラムが追加されたものが返ります(copyしているので元のDataFrameには副作用なし)。

ということは、空のDataFrameを渡したら、カラムが追加された空のDataFrameが返ってくるはずですよね。
テスト駆動開発っぽくやるなら、まず空のケースのテストなんじゃないかと思ったのです。

空のDataFrameってどう作る?

空のケースのテストを書くにあたって苦労したのが、空のDataFrameの作り方。
現時点の結論は以下です。

empty_df = pd.DataFrame(
    {
        "column1": pd.Series(dtype="int64"),
        "column2": pd.Series(dtype="float64"),
        "column3": pd.Series(dtype="object"),
    }
)

pandasのドキュメントを中心に引いて考えていき、一度動かした後はChatGPTにやりたいことを伝えて書かせました。
苦闘したのよりはるかによい実装だったので、ChatGPT案を採用しています。

DataFrameのドキュメントにドンピシャなものは見つけられていませんが、@dataclassインスタンスからDataFrameを作れるといったことも知りました。
https://pandas.pydata.org/docs/reference/api/pandas.DataFrame.html

おまけ:空のDataFrameを入力する場合のテスト

% uvx --python 3.12 --with pandas pytest -v script.py
  • Python 3.12.5
  • pytest 8.3.3
  • pandas 2.2.3

Seriesが寄せ集まってDataFrameということなのかもしれないですね。
空のSeriesが集まったので、DataFrameとしても空。

終わりに

pandasで空のDataFrame、キーにカラム名、値にdtypeのみ指定(文字列で指定)したSeriesからなる辞書を渡して作れました!
DataFrameを加工する関数に自動テストを書く試みは、なかなかよいスタートを切ったんじゃないかと思います!

栄を中心に #でらますミリオン 回ってきました #ミリオンコラボ現場視察

はじめに

わたくし、765プロライブシアターで仕掛け人をしております、にっPと申します。

2024年11月8日から名古屋で開催されている「でらますミリオン」を巡ってきました。

目次

でらますミリオン 2024

12/22(日)まで開催中!

(各スポットについて、こちらの公式から引用していきます)

2023年に続いての開催です。
副業関係で名古屋に行く用事があり1、それを済ませた後に回ってきました。

でらますPASSPORTはとてもよかったです。
私はコンビニプリントしてL判で持ち歩きました(縦横比を変える必要あり)。

私がした準備は気になる投稿をまとめておいたくらい。

MIRAI TOWERの近くにいることは分かっていたので、周辺で気になるところから回り始めました。

栄駅周辺

中部電力 MIRAI TOWER

・展望台チケットをご購入の際にランダム配布(全2種)

若鯱家 錦店

MIRAI TOWERの前の通りを、MIRAI TOWERに向かって左に進む中で見つけました。

カレーうどん、おいしかった〜

妙香園 サカエチカ

サカエチカ店限定の、初雪のクリームグリーンティ!
撮影スポットもあります

ほうじ茶もゲット!

栄駅の地下は迷宮感がすごかったのですが、知っているスポット MIRAI TOWER とつながるのがS4出口でした

オアシス21 iセンター

地下鉄からは栄駅の4A出口に向かい、4A出口の階段は上らずに直進します。

フォトパネルがあるのと、ここで「でらます重ね捺しスタンプラリー台紙」を入手。
早速スタンプが押せます

また、(Webでも見られる)パンフレットの物理版もいただけます。

マンガ展 名古屋

オアシス21 iセンターを探していたらマンガ展も発見!
等身大アクリルスタンドが目を引きます。

私の中での名古屋名物四天王の1つ、小倉トースト2

めきょぱセンター

栄 森の地下街南四番街 15番出口付近

オアシス21 iセンター(4A出口)から地下鉄の出口案内を頼りに森の地下街を進んでいきました。

免許・パスポートでめきょぱなんですかね?

めきょぱセンター(15番出口)から三越が近いのですが、三越の中を通ってラシックに移動できます!

栄ラシック(LACHIC)

  • 三越は地下2Fから入り地下1Fへ
  • 地下1Fを対角線で横切ってラシック連絡通路へ(店内の地図参照)
  • ラシックに地下から入ったら7Fへ(入ったところのエスカレーターよりもう少し先のエスカレーターがおすすめ)
鳥開総本家

お昼どきを外したほうがいいんじゃないかと思います。
Pじゃない方がめちゃめちゃ並んでました。
フォトパネルだけ回収

矢場とん

こちらも大人気でした。
朋花さまフォトパネルだけ回収

三越の方へ戻ったのですが、地下1Fからサカエチカの方に出ました。
これでようやくスポットの位置関係を完全理解した感じです3

伏見駅周辺

美術館も科学館も朝から夕方まで1日楽しめるスポットだと思います。
今回はチケットを買わずに入れるところだけ回りました。

名古屋市美術館

ロコフォトパネル!!

名古屋市科学館

貴音さんのパネルを見るにはチケットを買う必要がありそうでした(けっこう並んでいたので今回は見送り)

カフェでもでらますのポスターを見かけました

名古屋駅周辺

青柳総本家

カエルまんじゅう!

JPタワービルの中のKITTEの1Fにあります4
栄の方から地下鉄で戻った後、1番・10番出口のほうの改札で出ます。
案内されるのはJRゲートタワービルなのですが、その先に目的のJPタワービルがあります

終わりに

でらますミリオン巡りとしてはWIPもいいところではありますが、限られた時間で可能な限り回って私としては満足しています!
重ね捺しスタンプラリーは、今回巡った範囲でどちらの台紙も半分未満という感じ。
期間内にまた行けたらいいんですが、アイマスエキスポなどイベントは目白押しなので難しいかな...

とはいえ、今回初めて回ったでらますミリオン、とってもよかったです!
開催ありがとうございます!

(夢色乙女ブランケットも持参してました)

かえるまんじゅうとほうじ茶、絶対相性いいですよね...


  1. 残り3つは、えびふりゃ~、味噌カツみゃー(ろこどる アニメ11話より)
  2. KITTE名古屋(キッテ ナゴヤ)オフィシャルホームページ