きり丸の技術日記

技術検証したり、資格等をここに残していきます。

【詳細不明】NoInspectionAvailable を回避する

始めに

※ 自宅で検証した際には実装できなかったので詳細は不明です。


テスト実装中に次のエラーが発生しました。

E sqlalchemy.exc.NoInspectionAvailable: No inspection system is available for object of type <class 'models.User'>

発生原因が不明ですが、発生しないように対応できたのでその件を記事にします。

環境

  • Python
    • 3.12.3
  • SQLAlchemy
    • 2.0.27
      • 発生したバージョン
    • 2.0.31
      • 再現できず

対応

インスタンスをcopyして対応しました。または、commitでも対応はできそうです。

もともとやりたかったことはDBの値をHTTPリクエストで更新できるかどうかをチェックすることでしたが、項目数が多かったのでDBにinsertしたインスタンスを元に加工していました。その加工方法が誤っており、エラーが発生した模様です。

import copy

def test_01(session: AsyncSession)
  user = User(name="test")
  session.add(user)
  await session.flush()

  # NOTE: この処理が非常に重要だった
  # DBを元に入力値で更新するテストのために加工
  copied_user = copy.copy(user.__dict__)
  _ = copied_user.pop("_sa_instance_state")
  copied_user['name'] = "testUpd"
  # copied_userをJSON化したりして、なんやかんやで入力用に加工

  # DBからuserを取得しようとしてエラー
  query: select = select(User).where(User.name == "test")
  actual = (await db.execute(query)).scalars().first()

原因

自宅で再現できなかったので想像です。


次のエラーはmodels.Userを操作したときにSQLAlchemyのメタデータが正しく取得できず、インスタンスとDBを紐づける操作ができなかった時に発生します。

E sqlalchemy.exc.NoInspectionAvailable: No inspection system is available for object of type <class 'models.User'>

今回、DBに登録したインスタンスを元に入力値を加工しようとしたので、HTTPリクエスト時のJSONBodyに不要だと判断したSQLAlchemyのMetadataの_sq_instance_stateを削除しています。

copied_user = user.__dict__
_ = copied_user.pop("_sa_instance_state")

SQLAlchemyを使用して同一トランザクション内で処理する場合、処理結果をキャッシュに保持していて、処理結果に紐づいたインスタンスを返却します。今回の例でいうと、useractualが別名ですが同一インスタンスを指しています。

user = User(name="test")
session.add(user)

...

query: select = select(User).where(User.name == "test")
actual = (await db.execute(query)).scalars().first()

そして、__dict__を使用したらインスタンスをDeepCopyできていると勘違いしたので、このcopied_userも同じインスタンスです。

copied_user = user.__dict__
_ = copied_user.pop("_sa_instance_state")

そのため、次のタイミングでインスタンスにマッピングしようとしたところ、本来マッピングできるはずのuserインスタンスが存在せずにメタデータのエラーが発生したと思われます。

query: select = select(User).where(User.name == "test")
actual = (await db.execute(query)).scalars().first()

だからこそ、copyでインスタンスをDeepCopyしたことにより、今回の事象を回避できたと推測してます。

copied_user = copy.copy(user.__dict__)

ソースコード

再現できなかったのでなし。

終わりに

起こった事象自体はかなりニッチな内容で、一般的に役に立たない記事かもしれませんが、NoInspectionAvailableで調べた際に私の記事が参考になって解決できると幸いです。

参考情報

類似情報

PythonでSoftAssersionをする(use pytest-check, avoid assertion roulette)

自動テストの原則として、1テストに1検証とした方が良いです。これが複数あった時には失敗箇所が分かりづらくなってしまうため、その状態をバッドパターンとしてAssertion Rouletteと呼びます。

今回の記事では、PythonのTestフレームワークであるPytestを拡張しているpytest-checkを使用してAssertion Rouletteを回避するのを紹介します。

環境

  • Python
    • 3.12.3
  • pytest-check
    • 2.3.1

使用方法

Assertion Rouletteを回避するなら、pytest-checkを依存関係に含めてcheck.xxxメソッドを使用するだけです。これをするだけでテストが途中で止まらず、最後にエラー箇所をまとめてくれます。

from pytest_check import check

def test_01():
  check.equal(1, 2)
  check.equal(2, 3)

  # これと等価
  assert 1 == 2
  assert 2 == 3

def test_httpx_get_with_helpers():
    r = httpx.get('https://www.example.org/')
    assert r.status_code == 200
    check.is_false(r.is_redirect)
    check.equal(r.encoding, 'utf-8')
    check.is_in('Example Domain', r.text)

備考

その他にも、is_(a, b, msg="")almost_equal(a, b, rel=None, abs=None, msg="")等々の便利なチェックメソッドがあるので、もしpytest-checkを使用する場合はこちらのチェックメソッドで表現を増やすといいと思います。

個人的には難しくなるので、equalだけ覚えれば十分だと考えてます。エラーメッセージが丁寧になる等のメリットはあるのですが、pytest-checkがデファクトスタンダードと呼べるようなライブラリではないのでデメリットの方が多いと考えてます。


ただ、正直なところassertだとIDEのサポートで文字色を変更する等のサポートをしてくれるのに、checkだとデフォルトで色がつかないこともあって実適用は避けました。便利ではあるけれど、チームに強要するほどのメリットが見当たらなかったです。

ソースコード

なし

終わりに

いいライブラリではあるのですが、GitHub Star数が300強程度しかないので、どうしてもAssertion Rouletteが我慢できないときに導入するライブラリかなと考えてます。

ちなみに、short test summary infoにエラーメッセージが出ないことも問題だったので、PullRequestを出してマージしてもらってます。

こんな感じでOSSを積極的に利用して、気になった点はPullRequestで修正依頼を出せるようなエンジニアムーブをどんどんやっていきたいです。

Pythonのpandasで月の第2営業日を求める

始めに

各月の第2営業日に処理したいバッチ処理がありました。

PythonのpandasのCustomBusinessMonthBeginで営業日を計算できそうだったので、各月の営業日を求める方法を記載します。

環境

  • Python
    • 3.12.3
  • holidays
    • 0.50
  • pandas
    • 2.2.2

実装

祝日と営業日について

前回の記事である程度解説しているので、前回の記事を参考にしてください。

月最初の営業日を求める

CustomBusinessMonthBeginで月の最初の営業日を求められます。今回の記事ではエイリアスのCBMonthBeginを使用します。

月の最初の営業日 + 求めたい営業日 - 1で計算できます。今回は第2営業日を求めたいので、2 - 1 = +1します。

# 営業日のインスタンスを生成する
b_day = CDay(n=2 - 1, weekmask="Mon Tue Wed Thu Fri",
            holidays=jpholidays)

# 月初営業日のインスタンスを生成する。
b_month_begin = CBMonthBegin(weekmask=b_day.weekmask,
                            holidays=b_day.holidays)

# 月初日のインスタンスを生成する
base = pd.Timestamp(2024, 6, 1)

# 月初営業日まで日付を移動させる
b_base = b_month_begin.rollforward(base)

# 月初日営業日 + 加算する営業日
result = b_base + b_day

最終コード

最終的には次のようなコードで目的の月の第2営業日を求められます。

@pytest.mark.parametrize(
    "year, month, expected",
    [
        (2024, 1, "2024-01-03"),
        (2024, 2, "2024-02-02"),
        (2024, 6, "2024-06-04"),
    ]
)
def test_second_business_date(year, month, expected):
    """
    第二営業日 を求める
    """
    jpholidays = holidays.Japan(years=[year])

    add_b_day = 2 - 1
    b_day = CDay(n=add_b_day, weekmask="Mon Tue Wed Thu Fri",
                 holidays=jpholidays)

    b_month_begin = CBMonthBegin(weekmask=b_day.weekmask,
                                 holidays=b_day.holidays)

    base = pd.Timestamp(year, month, 1)
    b_base = b_month_begin.rollforward(base)
    assert b_base + b_day == pd.Timestamp(expected)

ソースコード

終わりに

CBMonthBeginの扱い方が分からなくて長時間ハマりました。

正直、本当にやりたいことはバッチをキックする側のスケジューラを設定することですが、とりあえず判別できるようになったので良しとします。

類似情報