きり丸の技術日記

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

Pythonのpandasで3営業日後を調べる

Pythonで営業日換算するときにpandasの型に営業日換算できる型があったので素振りしました。

なお、個人的にpolarsというpandasよりも高速処理できるライブラリに移行しようとしていますが、polars側には今回紹介する型がないので自作する必要があります。

環境

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

実装

祝日

日本の祝日を含めて営業日換算する必要があるので、定義する必要があります。

自分で定義してもよいのですが、今回は日本の祝日も取り扱っているライブラリのholidaysを使用します。

次のように呼び出すと、2024年の日本の祝日をset型で返却してくれます。なお、yearsの指定がない場合は返却されないので注意してください。

jp_holidays = holidays.Japan(years=[2024])

注意点としては、三が日に関しては「労働基準法上の扱いは、国民の祝日と同じ法定外休日」らしいのですが、祝日としては制定されていないので明示的に追加しないといけません。

日曜日が祝日だった時の振替休日に関しては、ライブラリ側で定義しています。

営業日

pandasCustomBusinessDayというカスタマイズできる営業日の型があります。今回の記事ではCustomBusinessDayのエイリアスでCDayがあるので、そちらを利用しています。

from pandas.tseries.offsets import CDay

CDayweekmaskを変更することで平日だけでなく土日を営業日として換算できます。また、holidaysに先ほどの祝日をパラメータとして渡すことで、自動的に祝日をスキップしてくれます。

add_b_day = 3
b_day = CDay(n=add_b_day, weekmask="Mon Tue Wed Thu Fri",
        holidays=jp_holidays
      )

当日を含めないで計算する場合

上の計算のままでよいです。

当日を含めて計算する場合

当日を含めて計算する場合は、次のように分類します。

  • 営業日
    • マイナス1(含めるため)
  • 営業日以外(土日・祝日)
    • 加工なし

そのため、次のロジックになります。

base = pd.Timestamp.now() # 基準日

base_is_weekend = base.dayofweek >= 5 # 土・日の判定
base_is_holiday = base in jp_holidays # 祝日の判定
add_b_day = 3 - (0 if (base_is_weekend or base_is_holiday) else 1)

最終コード

テストコードを含めて次のようなコードになります。テストコードで3営業日だと土日を跨がない可能性があるので、10営業日しています。

@pytest.mark.parametrize(
    "year, month, date, expected",
    [
        (2024, 5, 2, "2024-05-20"), # 通常営業日
        (2024, 5, 3, "2024-05-20"), # 3日は祝日
        (2024, 5, 11, "2024-05-24"), # 11日はただの土曜
    ]
)
def test_add_ten_business_date_ignore_base(year, month, date, expected):
    base = pd.Timestamp(year, month, date)

    jp_holidays = holidays.Japan(years=[year])
    add_b_day = 10
    b_day = CDay(n=add_b_day, weekmask="Mon Tue Wed Thu Fri",
                 holidays=jp_holidays
                 )
    assert base + b_day == pd.Timestamp(expected)


@pytest.mark.parametrize(
    "year, month, date, expected",
    [
        (2024, 5, 2, "2024-05-17"), # 通常営業日
        (2024, 5, 3, "2024-05-20"), # 3日は祝日,
        (2024, 5, 11, "2024-05-24"), # 11日はただの土曜
    ]
)
def test_add_ten_business_date_include_base(year, month, date, expected):
    base = pd.Timestamp(year, month, date)

    base_is_weekend = base.dayofweek >= 5 # 土・日の判定
    base_is_holiday = base in jp_holidays
    add_b_day = 10 - (0 if (base_is_weekend or base_is_holiday) else 1)
    b_day = CDay(n=add_b_day, weekmask="Mon Tue Wed Thu Fri",
                 holidays=jp_holidays
                 )
    assert base + b_day == pd.Timestamp(expected)

ソースコード

終わりに

ChatGPTに嘘をつかれたので、かなりこのロジックにたどり着くまでに苦労しました。

個人としてはpandasは新規採用しないと思いますが、今後もpandasを使う現場は多いと思うので、ぜひ役に立てば幸いです。

参考情報

SQLAlchemyで同一項目でアプリとDBの値を変更する(hybrid_property)

例えばDBで論理削除しているユーザを画面上で表示する際に「削除済ユーザ」と表示したい。PythonのSQLAlchemyであれば、画面上の表示は「削除済ユーザ」としつつ、DBの値は元の「きり丸」としておくことが可能です。

今回の記事では、SQLAlchemyを使用してアプリ上では「削除済ユーザ」、DB上では元のまま「きり丸」とする方法を記載いたします。

なお、誤った使用をしていると意図せずエラーになってしまうので気を付けましょう。

環境

  • Python
    • 3.12.3
  • SQLAlchemy
    • 2.0.30

使用方法

次のようにhybrid_propertyを使用することで、アプリ上では「削除済ユーザ」として表示し、DBとしては元のまま検索が可能です。

from sqlalchemy.ext.hybrid import hybrid_property
from sqlalchemy.orm.attributes import InstrumentedAttribute


class User(Base):
    __tablename__ = "users"

    __name: Mapped[str] = mapped_column("name", VARCHAR(1024))
    soft_destroyed_at: Mapped[datetime | None] = mapped_column(DATETIME, nullable=True)

    @hybrid_property
    def name(self) -> str:
        if self.soft_destroyed_at is None:
            return self.__name
        return "削除済ユーザ"

    @name.inplace.setter
    def _name__setter(self, name: str) -> None:
        self.__name = name

    @name.inplace.expression
    @classmethod
    def _name_expression(cls) -> InstrumentedAttribute[str]:
        return cls.__name

危険点

SQLAlchemyだけで完結する方法ではありますが、基本的にはアプリ上の値とDB上の値を一つのプロパティで扱う方法のため、失敗に気付き辛いかなり危険な方法ではあります。

名前であれば問題ないのですが、email等の別のテーブルとマッチングする可能性があるプロパティに関して行うと、インスタンスの値を元にした検索ができなくなります。

async def test_cant_find_app_name(self, db: AsyncSession):
    """
    DBの kirimaru ではなく、Appで加工した「削除済ユーザ」で検索するので、Appを経由すると取得できない。
    """
    user1 = User(
        name="kirimaru",
        soft_destroyed_at=datetime.now(),
    )
    db.add(user1)
    name = user1.name # ココが既に「削除済ユーザ」
    await db.commit()

    # 「削除済ユーザ」で検索をかけている
    query: Select = select(User).where(User.name == name)
    result = (await db.execute(query)).scalars().first()
    assert result is None

具体的な回避方法としては、インスタンスとDBが別の値を持つことが問題なので、インスタンスを経由せずにjoin等を使用して検索をすると問題なく取得できます。

または、SQLAlchemyはあくまでDBの値を扱うORMとして振舞い、画面表示用のデータとしてはpydantic等のライブラリ側で制御するのが良いです。

ソースコード

終わりに

うまく使えば便利ではありますが、一つのプロパティで別の挙動をするのは人間には理解しづらいです。理解していても、新規参画者にとっては同じミスを行いかねないので難しいですね。

SQLAlchemyはDBと接続するORMの役割だけを期待するようにすると良い気がします。

参考情報

Pythonでswitch文を書きたい(match, パターンマッチング)

Pythonでは3.10からパターンマッチングができるようになりました。Pythonではswitchではなく、matchで他言語のswitchと同等機能を提供できます。なお、当然ながらswitch文がない3.10以前はif... elif... elif... elseしかできないです。

環境

  • Python
    • 3.12.3

リテラルマッチング

通常のリテラルマッチングができます。A or B 等のor条件は|で設定できます。また、Pythonでdefaultにあたるのは_を使用します。

次の例は、環境がローカルならダミークラス、それ以外の環境なら本物のクラス、もし未定義の環境の場合はエラーとなるようにしています。

def get_mailer_class(env: str):
    match env:
        case "local":
            return DummyMailer
        case "dev" | "prod":
            return Mailer
        case _:
            raise ValueError(f"Unknown case: {env}")

class DummyMailer:
    pass

class Mailer:
    pass

クラスマッチング

クラスでの比較ができます。今回の例では、X軸とY軸をもつPointクラスを例にします。dataclassを使用していれば、__eq__()を自動で生成してくれるので、今回はdataclassで生成します。

from dataclasses import dataclass

@dataclass
class Point:
    x: int
    y: int


def match_point(point: Point):
    match point:
        case Point(x=0, y=0):
            return "Origin"
        case Point(x=0, y=_):
            return "Y-axis"
        case Point(x=_, y=0):
            return "X-axis"
        case Point():
            return "Somewhere else"

複数条件のマッチング

複数条件を定義してマッチングできます。例としてはバラバラのパラメータで受け取ってますが、実際に使用する際にはタプルで使用したほうが楽だと思います。

def match_point2(self, x: int, y: int):
    match x, y:
        case 0, 0:
            return "Origin"
        case 0, _:
            return "Y-axis"
        case _, 0:
            return "X-axis"
        case _:
            return "Somewhere else"

ソースコード

終わりに

Pythonでswitch文を書きたい時にChatGPTに相談したら出てこなかったので、自分でブログにしました。なお、キーワードさえ分かっていればGoogle検索できるので、このブログは自分へのメモの用途が大きいです。

参考情報