PythonとJavaScript で空の配列の真偽値が違う件について

Python

>>> bool([])
False

JavaScript

> Boolean([])
true

どっちが良いかと言われると難しいなあ。

どちらにせよ、配列そのものを真偽値チェックに使うのはバグの温床になるから止めたほうがいいね。lengthをチェックするとか変数定義をチェックするとかにした方がいい。

fetchにタイムアウトとリトライの処理を付ける

axios(+ axios-retry)だとタイムアウトとリトライの処理があるけど、fetchには何もないらしい。まじか。

手続き型言語なら普通にタイマーでキャンセル処理を登録してリトライはforループとかで、、

for(1 .. retry_num){
  if (timer(fetch, timeout) == 0){
    break
  }
}

とこんな感じのコードで済むんだけど、javascriptは何かと面倒だな。
色々調べてこんな感じになった。

function fetch_retry(url, params={}) {
    const {retry=5, timeout_ms=1000, options={}} = params;

    const controller = new AbortController();
    const timer = setTimeout(() => controller.abort(), timeout_ms);
    options["signal"] = controller.signal;

    return fetch(url, options)
    .catch((error) => {
        if (retry === 1) throw error;
        return fetch_retry(url, {retry : retry-1, timeout_ms : timeout_ms, options : options});
    })
    .finally(() => clearTimeout(timer));
}

こんな感じで使いたい。

fetch_retry(url)
fetch_retry(url, {timeout_ms:10000})

fetchを中断させるには AbortController().abort() を使うらしい。
おそらくこれを実行するとfetchのoptionに指定したsignalが送られてコネクションを閉じるんだと思う。

Promise.race()を使って、指定した時間でrejectを実行するPromiseと同時に動かすという方法もあるようだけど、これだと処理的にはタイムアウトするけどコネクションはそのままになってしまう。
まあ放っておけばkeep-aliveでcloseするだろうから(keepalive有効になってないことなんてないよね?)大きな問題ではなさそうだけど
fetch自体に備わってるabort処理を使う方が素直な実装でしょう。Promise.race()も使わずにすむし。

で、前のエントリーに合わせてStreams対応させれば。。もうちょっとだ。

参考:

fetchでReadableStreamを使う

var ts = function(){
  dt = new Date();
  year = dt.getFullYear();
  month = (dt.getMonth()+1).toString().padStart(2, '0');
  day = dt.getDate().toString().padStart(2, '0');
  hour = dt.getHours().toString().padStart(2, '0');
  min  = dt.getMinutes().toString().padStart(2, '0');
  sec  = dt.getSeconds().toString().padStart(2, '0');
  ms = dt.getMilliseconds();

  str = year + "-" + month + "-" + day + " " + hour + ":" + min + ":" + sec + "." + ms
  return str
}

var url = "<big data URL>"

fetch(url)
  .then((response) => response.body.getReader())
  .then((reader) => {
    var charsReceived = 0;

    function processText({ done, value }) {
      if (done) {
        console.log("Stream complete");
        return;
      }
      charsReceived += value.length;
      console.log( ts() + " " + value.length + " bytes received. total size=" + charsReceived + " bytes");

      return reader.read().then(processText);
    }
    return reader.read().then(processText);
  });

実行結果

2023-09-09 11:34:19.427 16384 bytes received. total size=16384 bytes
2023-09-09 11:34:19.519 32768 bytes received. total size=49152 bytes
2023-09-09 11:34:19.522 32274 bytes received. total size=81426 bytes
2023-09-09 11:34:19.614 65536 bytes received. total size=146962 bytes
2023-09-09 11:34:19.614 32491 bytes received. total size=179453 bytes
2023-09-09 11:34:19.618 16098 bytes received. total size=195551 bytes
2023-09-09 11:34:19.708 65536 bytes received. total size=261087 bytes
2023-09-09 11:34:19.709 36951 bytes received. total size=298038 bytes
Stream complete

とりあえず動いた。

参考:

疑問点

  • そもそもPythonのdatetimeっぽい時刻表示にするためにあんな泥臭いことやらないといけない?strftimeとかないの?
  • printf()あるいはformat()みたいな文字列フォーマットはないの?
  • reader.read() が完了する(fulfilledになる)タイミングはどこで決まってる?変更できるのかな?
  • 再起じゃなくてループにはできないのかな?awaitしないとwhileループで書けない?

javascript慣れてないからお作法というか文化的なものが分からんなあ。。

追記

async版。こっちの方が分かりやすいな。Promise分かんなすぎ。

(async () => {
  var charsReceived = 0;
  const response = await fetch(url);
  const reader = response.body.getReader();
  while (true) {
    const {value, done} = await reader.read();
    if (done) break;
    charsReceived += value.length;
    console.log( ts() + " " + value.length + " bytes received. total size=" + charsReceived + " bytes");
  }
  console.log('Stream complete');
})()


あとちなみに、valueはバイト列(Uint8Array)として渡されるので、テキストとして扱う場合はTextDecoder()でデコードするか
TextDecoderStream()をTransformStreamとして指定すればよいらしい。

const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();

わぁーおしゃれ!

追記2

最初は一番下のreturn reader.read().then(processText)の中にprocessTextの定義を埋め込んでたんだけど、そうすると途中でPromiseが切れる(という言い方が適切なのかは分からない)というか
.thenで繋いだときに読み込みが終わる前に実行されてしまうので分けて定義しないといけないみたい。

GmailのSMTPでOAuth2.0を使う in Perl

こちらの記事の続き。

kkobayashi-a.hatenablog.com

IMAPに比べてSMTPでOAuth 2.0の認証をするサンプルコードについてはほとんど情報がありませんでしたが、唯一こちらが参考になりました。

www.perlmonks.org

Gmail用のAuthen::SASLオブジェクトをAuthen::SASL::Perl::XOAUTH2として定義していて、これをそのまま使えば良さそう。

package Authen::SASL::Perl::XOAUTH2 ;

use strict ;
use warnings ;

our $VERSION = "0.01c" ;
our @ISA = qw( Authen::SASL::Perl ) ;

my %secflags = ( ) ;

sub _order { 1 }

sub _secflags {
  shift ;
  scalar grep { $secflags{$_} } @_ ;
}

sub mechanism {
    # SMTP->auth may call mechanism again with arg $mechanisms
    #            but that means something is not right
    if ( defined $_[1] ) { die "XOAUTH2 not supported by host\n" } ;
    return 'XOAUTH2' ;
} ;

my @tokens = qw( user auth access_token ) ;

sub client_start {
    # Create authorization string:
    # "user=" {User} "^Aauth=Bearer " {Access Token} "^A^A"
    my $self = shift ;
    $self->{ error } = undef ;
    $self->{ need_step } = 0 ;
    return
        'user=' .
        $self->_call( $tokens[0] ) .
        "\001auth=" .
        $self->_call( $tokens[1] ) .
        " " .
        $self->_call( $tokens[2] ) .
        "\001\001" ;
}

1 ;

他に使う機会もないし、スクリプトにそのまま埋め込めばいいかと思っていたけど
これを使うNet::SMTPS(Net::SMTP)内部でreuireする処理があるので
実ファイルとして@INCのパスの通ったディレクトリに保存する必要がありました。

Net::SMTPSから直接送信

とりあえずシンプルな例としてNet::SMTPSから直接叩いてメールを送るサンプルです。

最もシンプルな例としてはAuthen::SASLオブジェクト経由ではなく
直接 Net::SMTPS->command()で認証コマンドを実行することになりますが、
勉強も兼ねて公開されているAuthen::SASL::Perl::XOAUTH2を活かす方向で行きます。

use strict;
use warnings;
use utf8;
use Encode qw /encode/;

use Net::SMTPS;
use Authen::SASL qw/Perl/;
use Email::MIME;

my $USER_MAIL = 'kobayashi01234@gmail.com';
my $access_token = '[my access token]';

my $email = Email::MIME->create( header => [
  From    => $USER_MAIL,
  To      => $USER_MAIL,
  Subject => 'test mail',
  ],
  attributes => {
    content_type => 'text/plain',
    charset      => 'UTF-8',
    encoding     => '8bit',
  },
  body => encode('utf8', "テストメール"),
);
my $msg_string = $email->as_string;

my $sasl = Authen::SASL->new(
  mechanism => 'XOAUTH2',
  callback => {
    user => $USER_MAIL,
    auth => 'Bearer',
    access_token => $access_token,
  }
);

my $smtp = Net::SMTPS->new(
  'smtp.gmail.com',
  Port  => 587,
  doSSL => 'starttls',
  Debug => 1
);

$smtp->auth($sasl) or die "Can't authenticate:" . $smtp->message();
$smtp->mail($USER_MAIL);
$smtp->recipient($USER_MAIL);
$smtp->data();
$smtp->datasend($msg_string);
$smtp->dataend();

実行結果

Net::SMTPS=GLOB(0x80009c5f0)<<< 220 smtp.gmail.com ESMTP
Net::SMTPS=GLOB(0x80009c5f0)>>> EHLO localhost.localdomain
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-smtp.gmail.com at your service, [39.111.129.226]
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-SIZE 35882577
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-8BITMIME
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-STARTTLS
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-ENHANCEDSTATUSCODES
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-PIPELINING
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-CHUNKING
Net::SMTPS=GLOB(0x80009c5f0)<<< 250 SMTPUTF8
Net::SMTPS=GLOB(0x80009c5f0)>>> STARTTLS
Net::SMTPS=GLOB(0x80009c5f0)<<< 220 2.0.0 Ready to start TLS
Net::SMTPS=GLOB(0x80009c5f0)>>> EHLO localhost.localdomain
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-smtp.gmail.com at your service, [39.111.129.226]
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-SIZE 35882577
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-8BITMIME
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-AUTH LOGIN PLAIN XOAUTH2 PLAIN-CLIENTTOKEN OAUTHBEARER XOAUTH
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-ENHANCEDSTATUSCODES
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-PIPELINING
Net::SMTPS=GLOB(0x80009c5f0)<<< 250-CHUNKING
Net::SMTPS=GLOB(0x80009c5f0)<<< 250 SMTPUTF8
Net::SMTPS=GLOB(0x80009c5f0)>>> AUTH XOAUTH2 XXXXXXXXXX
Net::SMTPS=GLOB(0x80009c5f0)<<< 235 2.7.0 Accepted
Net::SMTPS=GLOB(0x80009c5f0)>>> MAIL FROM:<kobayashi01234@gmail.com>
Net::SMTPS=GLOB(0x80009c5f0)<<< 250 2.1.0 OK
Net::SMTPS=GLOB(0x80009c5f0)>>> RCPT TO:<kobayashi01234@gmail.com>
Net::SMTPS=GLOB(0x80009c5f0)<<< 250 2.1.5 OK
Net::SMTPS=GLOB(0x80009c5f0)>>> DATA
Net::SMTPS=GLOB(0x80009c5f0)<<< 354  Go ahead
Net::SMTPS=GLOB(0x80009c5f0)>>> From: kobayashi01234@gmail.com
Net::SMTPS=GLOB(0x80009c5f0)>>> To: kobayashi01234@gmail.com
Net::SMTPS=GLOB(0x80009c5f0)>>> Subject: test mail
Net::SMTPS=GLOB(0x80009c5f0)>>> Date: Wed, 15 Jun 2022 16:35:50 +0900
Net::SMTPS=GLOB(0x80009c5f0)>>> MIME-Version: 1.0
Net::SMTPS=GLOB(0x80009c5f0)>>> Content-Type: text/plain; charset=UTF-8
Net::SMTPS=GLOB(0x80009c5f0)>>> Content-Transfer-Encoding: 8bit
Net::SMTPS=GLOB(0x80009c5f0)>>>
Net::SMTPS=GLOB(0x80009c5f0)>>> テストメール
Net::SMTPS=GLOB(0x80009c5f0)>>> .
Net::SMTPS=GLOB(0x80009c5f0)<<< 250 2.0.0 OK  1655278555
Net::SMTPS=GLOB(0x80009c5f0)>>> QUIT
Net::SMTPS=GLOB(0x80009c5f0)<<< 221 2.0.0 closing connection

いい感じですね!

Email::Senderから送信

ようやく最終目標であるEmail::Senderから送る方法を考えます。
Net::SMTP(S)をそのまま使ってもいいですが、Email::Senderがいい感じにラップしてくれるので
モダンなPerlコードはこれを使うみたいです。

Email::Senderを使うにはGmailの認証に対応したEmail::Sender::Transportが必要になりますが
うまい具合に指定する方法が見つからなかったので、強引にEmail::Sender::Transport::SMTPを上書き(継承)した
Email::Sender::Transport::SMTP::Gmailクラスを作成します。

sendmail()の処理では_smtp_client()関数からsmtpオブジェクトの生成や認証を行うのですが、
コンストラクタで認証済みのNet::SMTPオブジェクトをセットし、それをそのまま返すようにしています。

package Email::Sender::Transport::SMTP::Gmail;

use strict;
use warnings;
use base qw(Email::Sender::Transport::SMTP);

sub new{
  my $this = shift;
  my $class = ref $this || $this;
  return bless {_smtps_client => $_[0]}, $class;
}

sub _smtp_client{
  return $_[0]->{_smtps_client};
}

1;

package main;

use strict;
use warnings;
use utf8;
use Encode qw /encode/;

use Net::SMTPS;
use Authen::SASL qw/Perl/;
use Email::MIME;
use Email::Sender::Simple qw(sendmail);

my $USER_MAIL = 'kobayashi01234@gmail.com';
my $access_token = '[my access token]';

my $email = Email::MIME->create( header => [
  From    => $USER_MAIL,
  To      => $USER_MAIL,
  Subject => 'test mail',
  ],
  attributes => {
    content_type => 'text/plain',
    charset      => 'UTF-8',
    encoding     => '8bit',
  },
  body => encode('utf8', "テストメール"),
);
my $msg_string = $email->as_string;

my $sasl = Authen::SASL->new(
  mechanism => 'XOAUTH2',
  callback => {
    user => $USER_MAIL,
    auth => 'Bearer',
    access_token => $access_token,
  }
);

my $smtp = Net::SMTPS->new(
  'smtp.gmail.com',
  Port  => 587,
  doSSL => 'starttls',
  Debug => 1
);
$smtp->auth($sasl) or die "Can't authenticate: " . $smtp->message();

my $sender = Email::Sender::Transport::SMTP::Gmail->new($smtp);

sendmail($email, {transport => $sender});

GmailのIMAPでOAuth2.0を使う in Perl

GmailIMAPログインが失敗するようになってしまった。

Google アカウントが5月30日にセキュリティ強化、Gmailの外部メールアプリ利用などが使えなくなる可能性 - ケータイ Watch

これのことらしい。困りますね。

use strict;
use warnings;
use utf8;

use IO::Socket::SSL;
use Net::IMAP::Client;

my $imap = Net::IMAP::Client->new(
  server => 'imap.gmail.com',
  port   => 993,
  ssl    => 1,
  user   => '[user gmail address]',
  pass   => '[password]',
);
die "Could not connect to IMAP server" unless $imap;

$imap->login or die('Login failed: ' . $imap->last_error);
$ perl gmail_test.pl
Login failed: [AUTHENTICATIONFAILED] Invalid credentials (Failure) at gmail_test.pl line 17.

ユーザーID+パスワードではなく、OAuth2.0を使ってログインする必要があるそうです。
しかしOAuthとかすっかり忘れてしまった。
自分の過去記事でもまとめてるけど、何しろ10年くらい前の話(!)なので勉強し直しですな。。

大まかな手順としては以下のような感じ。

  1. Googleのproject(application)を作成
  2. OAuth2.0用のClient IDを作成
  3. ↑のClient IDから使用するGmailのアカウントでOAuth2.0認証してアクセストークンを取得
  4. アクセストークンを使ってIMAP認証

とりあえず公式ドキュメントのこの辺を読んでおけばよさそう。

動くPerlのコードサンプルとしてはこちらが参考になりました。

lowreal.net

しかしやっぱりWeb周りに関してはPerlよりPythonの方が簡単だなあ。Googleが提供してるライブラリもPerlはないし干されている。

project作成

Google API Consoleを開いてプロジェクトを作成する。
色々と項目があって混乱するけど、実際やることはほとんどない。

  1. project作成
  2. APIs & Servicesを選択
  3. OAuth consent screenを設定
    • Scopesは何も指定しない
    • Publishing status を productionにする
  4. CredentialsからOAuth 2.0 Client IDを作成
    • Application typeはDesktop app
  5. client_secret.json をダウンロード
メモ

Gmail APIを有効にする必要があるかな?と思ったけど特にAPIの有効化やscopeの設定は必要ない。
今回はAPIを使うわけではなく認証するだけ(実際の処理はIMAP/SMTPで行う)だからなのかな?

また、ScopeにGmail API(https://mail.google.com/)を指定するとpublishing statusをproductionにする際verificationが必要になる。verificationには色々手順が必要らしく面倒そう。
support.google.com

しかしpublishing statusがtestのままだと7日ごとにrefresh tokenの取り直し(OAuth2.0の再認証?)が必要になるのでやっぱり面倒そう。

ということで、scopeを空にしてpublishing statusをproductionにする。これだとverificationが不要なので楽だと思う。

Client IDデータ(client_secret.json)

ダウンロードしたclient_secret.jsonはこういう感じのデータになってるので、これをそのまま使います。

redirect_urisはlocalhostになっていますが、"urn:ietf:wg:oauth:2.0:oob"に書き換えておきます。

{
  "installed": {
    "client_id": "XXXXXXXX",
    "project_id": "YYYYYYYY",
    "auth_uri": "https://accounts.google.com/o/oauth2/auth",
    "token_uri": "https://oauth2.googleapis.com/token",
    "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs",
    "client_secret": "ZZZZZZZZ",
    "redirect_uris": [
      "localhost"
    ]
  }
}

redirect URIの詳細な説明はこちらのドキュメントに載ってます。

github.com

GoogleのOAuth2.0認証

というわけで、参考サイトのドキュメントやサンプルコードを眺めつつOAuth2.0の認証とアクセストークンのrefreshを実装してみました。

use strict;
use warnings;
use utf8;
use Getopt::Std;

use JSON::XS;
use Path::Class qw/file/;
use URI;
use LWP::UserAgent;

my $opt = {};
getopts("r:", $opt);

my $client_secret = JSON::XS->new->utf8->decode(scalar file('client_secret.json')->slurp)->{installed};

if($opt->{r}){
  refresh($client_secret, $opt->{r});
}
else{
  auth($client_secret);
}

sub auth{
  my $client_secret = shift;

  my $auth_uri = URI->new($client_secret->{auth_uri});
  $auth_uri->query_form(
    response_type => 'code',
    client_id     => $client_secret->{client_id},
    redirect_uri  => $client_secret->{redirect_uris}->[0],
    scope         => 'https://mail.google.com/',
  );

  print "To authorize token, visit this url and follow the directions:\n$auth_uri\n";
  print "Enter verification code: ";

  my $authorization_code = <>;
  $authorization_code =~ tr/\x0A\x0D//d;

  my $ua = LWP::UserAgent->new();
  my $token_uri = URI->new($client_secret->{token_uri});
  my $res = $ua->post($token_uri, {
    client_id     => $client_secret->{client_id},
    client_secret => $client_secret->{client_secret},
    code          => $authorization_code,
    redirect_uri  => $client_secret->{redirect_uris}[0],
    grant_type    => 'authorization_code',
  });

  my $res_text = $res->decoded_content;
  my $res_json = JSON::XS->new->utf8->decode($res_text);

  print "\n";
  print "Refresh Token: $res_json->{refresh_token}\n";
  print "Access Token: $res_json->{access_token}\n";
  print "Access Token Expiration Seconds: $res_json->{expires_in}\n";
}

sub refresh{
  my ($client_secret, $refresh_token) = @_;
  my $ua = LWP::UserAgent->new();
  my $token_uri = URI->new($client_secret->{token_uri});
  my $res = $ua->post($token_uri, {
    client_id     => $client_secret->{client_id},
    client_secret => $client_secret->{client_secret},
    refresh_token => $refresh_token,
    grant_type    => 'refresh_token',
  });
  
  my $res_text = $res->decoded_content;
  my $res_json = JSON::XS->new->utf8->decode($res_text);

  print "\n";
  print "Access Token: $res_json->{access_token}\n";
  print "Access Token Expiration Seconds: $res_json->{expires_in}\n";
}

メール受信(IMAP

use strict;
use warnings;
use utf8;

use MIME::Base64;
use IO::Socket::SSL;
use Net::IMAP::Client;
use Data::Dumper;
use Time::Out qw /timeout/;

my $imap = Net::IMAP::Client->new(
  server => 'imap.gmail.com',
  port   => 993,
  ssl    => 1,
  timeout => 10,
);
die "Could not connect to IMAP server" unless $imap;

my $access_token = '[access token]';
my $user = '[user mail]';
my $auth_string = sprintf("user=%s\001auth=Bearer %s\001\001", $user, $access_token);

my $capability = $imap->capability;

my $ok;
timeout 5 => sub {
  ($ok) = $imap->_tell_imap('AUTHENTICATE' => 'XOAUTH2 ' . encode_base64($auth_string, ''));
};
if ($@){
  die("AUTHENTICATE timed out");
}
unless($ok){
  die('Login failed: ' . $imap->last_error);
}
$imap->select('INBOX');

Net::IMAP::Client->_tell_imap()というのが生のIMAPコマンドを投げる内部関数らしいので、これを使ってOAuthの認証情報を送ります。

ちなみにオリジナルのNet::IMAP::Client->loginはこんな感じ。

sub login {
    my ($self, $user, $pass) = @_;
    $user ||= $self->{user};
    $pass ||= $self->{pass};
    $self->{user} = $user;
    $self->{pass} = $pass;
    _string_quote($user);
    _string_quote($pass);
    my ($ok) = $self->_tell_imap(LOGIN => "$user $pass");
    return $ok;
}

このAUTHENTICATEコマンドで間違ったアクセストークンを投げるとエラーを返すと思っていたけど、
実際は応答がなくなってしまう(ハングする)のでTime::Outを使って強制的にタイムアウトさせてる。
コンストラクタにtimeoutオプションがあるけど、これはTCP connectのときのタイムアウトっぽくてコマンドのタイムアウトはしなかった。

メール送信(SMTP

SMTPの場合もOAuth2.0のやり方が書いてあるけどPerlでどう実装するかは要調査。
developers.google.com

とりあえず暫定対応としてユーザーとパスワードでSMTP AUTHができる別なメールサービスを使うことにした。
今後もこういうGoogle独自の認証が増えてくると面倒なので、シンプルなSMTP/IMAPが使えるメールサービスを契約しておくというのは現実的にアリかもしれない。

追記

できた。
kkobayashi-a.hatenablog.com

WindowsでPycURLを使う

PycURL – A Python Interface To The cURL library — PycURL 7.45.1 documentation

PythonでHTTP関係の処理をするならRequestsなのですが、PycURLはlibcurlベースで高速だったり細かい処理ができたりして便利なこともあるので使ってみます。

残念ながらWindows用のバイナリは公式に存在しないそうです。かといってソースからbuildするのもなあ。

検索してみるとこちらのサイトでバイナリを配布してるようなので使ってみます。

Python Extension Packages for Windows - Christoph Gohlke

自分の環境はこれなので "pycurl‑7.45.1‑cp38‑cp38‑win_amd64.whl" をダウンロードします。

$ python -VV
Python 3.8.6 (tags/v3.8.6:db45529, Sep 23 2020, 15:52:53) [MSC v.1927 64 bit (AMD64)]
$ pip install pycurl-7.45.1-cp38-cp38-win_amd64.whl

とりあえず動くサンプル。

from datetime import datetime, timedelta
import pycurl
from io import BytesIO

def report(curl):
    print("Performance report:")
    print("-----------------------------------------------------------------------")
    print("EFFECTIVE_URL      : {}".format(curl.getinfo(pycurl.EFFECTIVE_URL)))
    print("RESPONSE_CODE      : {}".format(curl.getinfo(pycurl.RESPONSE_CODE)))
    print("SIZE_DOWNLOAD      : {}".format(curl.getinfo(pycurl.SIZE_DOWNLOAD)))

    print("NAMELOOKUP_TIME    : {}".format(curl.getinfo(pycurl.NAMELOOKUP_TIME)))
    print("CONNECT_TIME       : {} {}".format(curl.getinfo(pycurl.CONNECT_TIME), curl.getinfo(pycurl.CONNECT_TIME)-curl.getinfo(pycurl.NAMELOOKUP_TIME)))
    # APPCONNECT : ssl_handshake_done
    print("APPCONNECT_TIME    : {} {}".format(curl.getinfo(pycurl.APPCONNECT_TIME), curl.getinfo(pycurl.APPCONNECT_TIME)-curl.getinfo(pycurl.CONNECT_TIME)))
    # Time to HTTP GET done
    # https://curl.se/libcurl/c/CURLINFO_PRETRANSFER_TIME.html
    print("PRETRANSFER_TIME   : {} {}".format(curl.getinfo(pycurl.PRETRANSFER_TIME), curl.getinfo(pycurl.PRETRANSFER_TIME)-curl.getinfo(pycurl.APPCONNECT_TIME)))
    # STARTTRANSFER : TTFB(time to first byte)
    print("STARTTRANSFER_TIME : {} {}".format(curl.getinfo(pycurl.STARTTRANSFER_TIME), curl.getinfo(pycurl.STARTTRANSFER_TIME)-curl.getinfo(pycurl.PRETRANSFER_TIME)))
    print("TOTAL_TIME         : {} {}".format(curl.getinfo(pycurl.TOTAL_TIME), curl.getinfo(pycurl.TOTAL_TIME)-curl.getinfo(pycurl.STARTTRANSFER_TIME)))
    print("REDIRECT_TIME      : {}".format(curl.getinfo(pycurl.REDIRECT_TIME)))
    print()

def _curl_debug(type, data):
    # CURLINFO_TEXT = 0,
    # CURLINFO_HEADER_IN,    /* 1 */
    # CURLINFO_HEADER_OUT,   /* 2 */
    # CURLINFO_DATA_IN,      /* 3 */
    # CURLINFO_DATA_OUT,     /* 4 */
    # CURLINFO_SSL_DATA_IN,  /* 5 */
    # CURLINFO_SSL_DATA_OUT, /* 6 */

    type_str = ('*', '<', '>', '{', '}', '<<', '>>')
    msg = None
    if type == 3 or type == 4:
        msg = "[{} bytes data]".format(len(data))
    else:
        msg = data.decode('utf-8').strip()

    print("{} {} {}".format(datetime.now(), type_str[type], msg))

buffer = BytesIO()
curl = pycurl.Curl()
curl.setopt(pycurl.URL, 'http://pycurl.io/docs/latest/index.html')
curl.setopt(pycurl.WRITEDATA, buffer)
curl.setopt(pycurl.FOLLOWLOCATION, True)
curl.setopt(pycurl.VERBOSE, True)
curl.setopt(pycurl.DEBUGFUNCTION, _curl_debug)
curl.perform()
print()
report(curl)
curl.close()
$ python a.py
2022-05-06 09:09:49.740667 * Trying 192.30.252.154:80...
2022-05-06 09:09:49.914677 * Connected to pycurl.io (192.30.252.154) port 80 (#0)
2022-05-06 09:09:49.914677 > GET /docs/latest/index.html HTTP/1.1
Host: pycurl.io
User-Agent: PycURL/7.45.1 libcurl/7.80.0 Schannel zlib/1.2.11 zstd/1.5.2 c-ares/1.18.1 libssh2/1.10.0
Accept: */*
2022-05-06 09:09:50.093687 * Mark bundle as not supporting multiuse
2022-05-06 09:09:50.094688 < HTTP/1.1 200 OK
2022-05-06 09:09:50.094688 < Server: GitHub.com
2022-05-06 09:09:50.094688 < Date: Fri, 06 May 2022 00:09:49 GMT
2022-05-06 09:09:50.094688 < Content-Type: text/html; charset=utf-8
2022-05-06 09:09:50.094688 < Content-Length: 22758
2022-05-06 09:09:50.094688 < Vary: Accept-Encoding
2022-05-06 09:09:50.094688 < Last-Modified: Sun, 13 Mar 2022 07:25:32 GMT
2022-05-06 09:09:50.094688 < Vary: Accept-Encoding
2022-05-06 09:09:50.094688 < Access-Control-Allow-Origin: *
2022-05-06 09:09:50.094688 < ETag: "622d9c6c-58e6"
2022-05-06 09:09:50.094688 < expires: Fri, 06 May 2022 00:19:49 GMT
2022-05-06 09:09:50.094688 < Cache-Control: max-age=600
2022-05-06 09:09:50.094688 < Accept-Ranges: bytes
2022-05-06 09:09:50.094688 < x-proxy-cache: MISS
2022-05-06 09:09:50.094688 < X-GitHub-Request-Id: E3E7:3DA7:4CD618:744035:6274674D
2022-05-06 09:09:50.094688 <
2022-05-06 09:09:50.094688 { [984 bytes data]
2022-05-06 09:09:50.094688 { [12924 bytes data]
2022-05-06 09:09:50.268697 { [5744 bytes data]
2022-05-06 09:09:50.268697 { [3106 bytes data]
2022-05-06 09:09:50.268697 * Connection #0 to host pycurl.io left intact

Performance report:
-----------------------------------------------------------------------
EFFECTIVE_URL      : http://pycurl.io/docs/latest/index.html
RESPONSE_CODE      : 200
SIZE_DOWNLOAD      : 22758.0
NAMELOOKUP_TIME    : 0.003169
CONNECT_TIME       : 0.177756 0.174587
APPCONNECT_TIME    : 0.0 -0.177756
PRETRANSFER_TIME   : 0.17809 0.17809
STARTTRANSFER_TIME : 0.356996 0.17890599999999998
TOTAL_TIME         : 0.531489 0.174493
REDIRECT_TIME      : 0.0

いいね。

PHPの http_build_query と Pythonの urlencode

シンプルなkey=valueの形式なら何を使っても同じ結果になるけど、ネストしたデータ構造だと結果が違う。

stackoverflow.com

元々はjQueryのparam()とPythonのurlencodeで結果が違うなーと思って(jQueryPHPと同じ結果になる)調べてたけど、どっちでもサーバー側では問題なく解釈されるみたい。まあサーバーの実装にもよるだろうけど。。

import requests
import urllib

def flatten(dictionary, parent_key=None):
    items = []
    for key, value in dictionary.items():
        new_key = "{}[{}]".format(str(parent_key), key) if parent_key else key
        if isinstance(value, dict):
            items.extend(flatten(value, new_key).items())
        elif isinstance(value, list) or isinstance(value, tuple):
            for k, v in enumerate(value):
                items.extend(flatten({str(k): v}, new_key).items())
        else:
            items.append((new_key, value))
    return dict(items)

form_data = {
    "k1" : "v1",
    "k2" : {
        "k2_1" : "v2_1",
        "k2_2" : "v2_2",
    },
    "k3" : ["v3_1", "v3_2", "v3_3", "v3_4"]
}

# urlencode
print(urllib.parse.urlencode(form_data))

# http_build_query compatible
print(urllib.parse.urlencode(flatten(form_data)))
$ python a.py  | nkf --url-input
k1=v1&k2={'k2_1':+'v2_1',+'k2_2':+'v2_2'}&k3=['v3_1',+'v3_2',+'v3_3',+'v3_4']
k1=v1&k2[k2_1]=v2_1&k2[k2_2]=v2_2&k3[0]=v3_1&k3[1]=v3_2&k3[2]=v3_3&k3[3]=v3_4

jQueryでは

decodeURI($.param(form_data))
"k1=v1&k2[k2_1]=v2_1&k2[k2_2]=v2_2&k3[]=v3_1&k3[]=v3_2&k3[]=v3_3&k3[]=v3_4"

jQueryのparam()は配列のインデックスを入れないの?

PHP環境がないので生のhttp_build_query()がどうなってるのか分からないけど、
とりあえず今回は配列データを使わないので気にしないことにした。