まず初めに、はっきりとさせておきたいことがあります。この問題の直接の原因がhueyやpeeweeではないということです。

huey.contrib.sql_huey.SqlHueyは引数databaseにDSNを取ります。DSNとは{schema}://{username}:{password}@{host}:{port}/{database}のような文字列です。具体例を上げるとhuey.contrib.sql_huey.SqlHuey(database='postgresql://postgres:password@localhost:5432/mydatabase')です。DSNはURIと同じ形式です。

このパスワードの部分がpass?wordのように?を含んでいると、パスワードがそこで切られてしまい、データベースに接続できなくなります。

パスワードにと言っていますが、ユーザ名などにあったとしても同じ問題を引き起こします。ただパスワード以外に問題を起こす一部の記号を使うことは滅多にないことでしょう。

Databaseオブジェクトを使えば回避可能

まずは問題を解決する方法を紹介します。

SqlHueyの取るdatabase引数は文字列だけでなく、peeweeのDatabaseオブジェクトも受け取れます。このDatabaseオブジェクトはデータベースのユーザ名やパスワードといった、先程DSN文字列で渡していたものを個別に保持できるクラスです。

from typing import Final

import huey.contrib.sql_huey
import peewee


database: Final = peewee.PostgresqlDatabase(
    user='postgres',
    password='pass?word',
    host='localhost',
    port=5432,
    database='mydatabase'
)
huey.ccontrib.sql_huey.SqlHuey(database=database)

PostgreSQL以外の場合

私がこの事象に遭遇したときにPostgreSQLを使っていたのでpeewee.PostgresqlDatabaseを使いました。MySQLを使うときにはpeewee.MySQLDatabase、SQLiteを使うときにはpeewee.SqliteDatabaseを使用してください。

PostgresqlDatabaseとMySQLDatabaseに大文字小文字の使い分けの統一がないので気をつけてください。

問題の解説

SqlHueyはDSN文字列を受け取った時、playhouseというpeeweeの拡張を使いデータベースに接続します。Playhouseはpeeweeの公式の拡張です。PlayhoustがPostgresqlDatabaseのようなDatabaseオブジェクトを文字列から作ってくれます。

今回はこのDSN文字列をパースする部分に問題がありました。DSNを受け取ったplayhouseはそのDSNを標準ライブラリの関数urllib.parse.urlparseに渡します。

実際に見てみましょう。

>>> import urllib.parse
>>> urllib.parse.urlparse('postgresql://postgres:password@localhost:5432/mydatabase')
ParseResult(scheme='postgresql', netloc='postgres:password@localhost:5432', path='/mydatabase', params='', query='', fragment='')

これはパスワードに?を含んでいないものです。

>>> import urllib.parse
>>> urllib.parse.urlparse('postgresql://postgres:pass?word@localhost:5432/mydatabase')
ParseResult(scheme='postgresql', netloc='postgres:pass', path='', params='', query='word@localhost:5432/mydatabase', fragment='')

これはパスワードに?を含んでいるものです。?以降がqueryになり、パスワードが途中で切られてしまっていることが分かります。

RFC 3986

urllib.parse.urlparseによってパースされていることから気が付かれた人もいることでしょうが、?以外にも問題を引き起こす文字があります。urllib.parse.urlparseRFC 3986で定義されているURIをパースするものです。

RFC 3986でユーザ名とパスワードを書く部分はuserinfoとしてこのように定義されています。

userinfo      = *( unreserved / pct-encoded / sub-delims / ":" )
unreserved    = ALPHA / DIGIT / "-" / "." / "_" / "~"
reserved      = gen-delims / sub-delims
sub-delims    = "!" / "$" / "&" / "'" / "(" / ")"
              / "*" / "+" / "," / ";" / "="

これによれば?が使えると定義されていないので、今回の例のDSNをパースできないのはurllib.parse.urlparseの問題ではありません。userinfoの定義を読めば、例えば/もうまくパースできないことが分かります。

>>> import urllib.parse
>>> urllib.parse.urlparse('postgresql://postgres:pass/word@localhost:5432/mydatabase')
ParseResult(scheme='postgresql', netloc='postgres:pass', path='/word@localhost:5432/mydatabase', params='', query='', fragment='')

結局誰が悪かったのか

PostgreSQLのドキュメントに私がDSNと呼んだものが接続URIとして説明されています。これは名前の通りURIなのですが拡張されています。しかしこの拡張は先のuserinfoに影響を与えるものではありません。

ということは、接続URIまたはDSNのパスワードに?を含んではいけないということです。なので結論はDSNのパスワード部分に?を入れた私が悪いということです。

パスワードに?を使うことはまったく問題ありません。ここは勘違いしてはいけないところです。