2018/06/26

Python Tips:月の初日や最終日を取得したい

Python で、月の初日や最終日を取得する方法をご紹介します。

標準ライブラリを使った方法


月の初日を取得する

月の初日を取得するには、 datetime.datetimedatetime.datereplace() メソッドを使った方法が便利です。

import datetime

def get_first_day_of_month(date=None):
    '''指定された日付の月の最初の日を返す'''
    if not date:
        date = datetime.date.today()
    return date.replace(day=1)

テストを書いてみましょう。

import unittest

class TestGetFirstDayOfMonth(unittest.TestCase):
    def test_特定の日(self):
        date = datetime.date(2018, 12, 15)
        first_day = get_first_day_of_month(date)

        self.assertEqual(first_day, datetime.date(2018, 12, 1))

    def test_当日(self):
        first_day = get_first_day_of_month()
        today = datetime.datetime.today()

        self.assertEqual(first_day.day, 1)
        self.assertEqual(first_day.month, today.month)
        self.assertEqual(first_day.year, today.year)

上の 2 つのコード片をあわせたスクリプトを python -m unittest スクリプト名 で実行すると、テストがパスすることが確認できます。

月の最終日を取得する

月の最終日を取得する場合は初日の場合より少し複雑です。月によって最終日が変わるからです。

calendarmonthrange() 関数には月の最終日を返す機能が備わっているのでこれを使用するのがかんたんです。

import calendar
import datetime

def get_last_day_of_month(date=None):
    '''指定された日付の月の最終日を返す'''
    if not date:
        date = datetime.date.today()
    last_day = calendar.monthrange(date.year, date.month)[1]
    return date.replace(day=last_day)

calendar.monthrange() は、年と月を引数に取り、要素数 2 のタプルを返します。タプルの要素は、その月の初日の曜日を表す整数( 0 が月曜日)と、その月の日数です。

import calendar

calendar.monthrange(2020, 7)
# => (2, 31)
# ( 2020 年 7 月の 1 日は水曜日で、日数は 31 日)

calendar.monthrange(2020, 8)
# => (5, 31)
# ( 2020 年 8 月の 1 日は土曜日で、日数は 31 日)

こちらもテストしてみます。

import unittest

class TestGetLastDayOfMonth(unittest.TestCase):
    def test_4月(self):
        date = datetime.date(2020, 4, 7)
        last_day = get_last_day_of_month(date)

        self.assertEqual(last_day, datetime.date(2020, 4, 30))

    def test_8月(self):
        date = datetime.date(2020, 8, 10)
        last_day = get_last_day_of_month(date)

        self.assertEqual(last_day, datetime.date(2020, 8, 31))

    def test_うるう年の2月(self):
        date = datetime.date(2020, 2, 5)
        last_day = get_last_day_of_month(date)

        self.assertEqual(last_day, datetime.date(2020, 2, 29))

こちらも実行するとすべてパスすることが確認できます。

前月の最終日を取得する

前月の最終日を取得する方法についてはアプローチがいくつか考えられますが、最もシンプルでかんたんなのは「前月の最終日 = 今月の初日の前日」と考える形ではないかと思います。

import datetime

def get_first_day_of_month(date=None):
    '''指定された日付の月の最初の日を返す'''
    if not date:
        date = datetime.date.today()
    return date.replace(day=1)

def get_last_day_of_prev_month(date=None):
    '''指定された日付の前月の最終日を取得する'''
    if not date:
        date = datetime.date.today()
    first_date = get_first_day_of_month(date)
    return first_date - datetime.timedelta(days=1)

こちらもテストを書いてみます。

import unittest

class TestGetLastDayOfPrevMonth(unittest.TestCase):
    def test_4月(self):
        date = datetime.date(2020, 4, 7)
        last_day = get_last_day_of_prev_month(date)

        self.assertEqual(last_day, datetime.date(2020, 3, 31))

    def test_1月(self):
        date = datetime.date(2020, 1, 20)
        last_day = get_last_day_of_prev_month(date)

        self.assertEqual(last_day, datetime.date(2019, 12, 31))

こちらも python -m unittest で実行すると、すべてパスすることが確認できます。

以上は Python に同梱の標準ライブラリを使った方法でした。続いて標準ライブラリ以外のパッケージを使った方法を見てみましょう。

標準ライブラリ以外のパッケージを使った方法


さまざまなパッケージがあるかと思うのですが、ここでは名前とインタフェースがイケている Delorean (デロリアン)というパッケージを使った方法をご紹介します。 Delorean を使うとかんたんにタイムトラベル(時間の変更)を行うことができます。




from delorean import Delorean

月の初日を取得する

from delorean import Delorean

def get_first_day_of_month2(delorean):
    '''指定された日付の月の初日を返す'''
    return delorean.truncate('month')

Delorean オブジェクトは datetime.datetime をラップしたオブジェクトです。アトリビュート datedatetime で日付や日時を返してくれます。

テストすると次のようになります。

import unittest

class TestGetFirstDayOfMonth2(unittest.TestCase):
    def test_4月(self):
        d = Delorean(datetime.datetime(2020, 4, 10), timezone='Asia/Tokyo')
        d_first = get_first_day_of_month2(d)

        self.assertEqual(d_first.date.day, 1)

        tz_tokyo = pytz.timezone('Asia/Tokyo')

        # *1
        self.assertEqual(
            d_first.datetime,
            tz_tokyo.localize(datetime.datetime(2020, 4, 1)),
        )

        # *2: pytz.timezone を datetime.datetime() に渡して
        # 使うと時間がズレることがあり次は等しくならない
        self.assertNotEqual(
            d_first.datetime,
            datetime.datetime(2020, 4, 1, tzinfo=tz_tokyo),
        )

以下少し本題から外れた余談です。

pytz のタイムゾーンが謎の LMT 9:19 になる問題

*2 のコメントに書いているとおり、 pytz.timezonedatetime.datetime() に渡して使うと思わぬ挙動をすることがあるので、この方法で日時オブジェクトを生成してはいけません。代わりに *1 のように pytz.timezone.localize() を使う必要があります。

というのは、 pytz.timezonedatetime.datetime() に渡して使うと、 pytz.timezone('Asia/Tokyo') が指すタイムゾーンが JST+9:00 ではなく LMT+9:19 になることがあるためです。

import pytz

# JST じゃない何か変なのが出てきた
pytz.timezone('Asia/Tokyo')
# => <DstTzInfo 'Asia/Tokyo' LMT+9:19:00 STD>

# localize() 後のものは正しい JST になっている
pytz.timezone('Asia/Tokyo').localize(2018, 5, 27).tzinfo
# => <DstTzInfo 'Asia/Tokyo' JST+9:00:00 STD>

この原因はどうも、 pytz のタイムゾーンオブジェクトは実はタイムゾーンではなく場所を表していて、時代によって指し示すタイムゾーンが自動的に切り替わるように作られているらしいのですが、日時をまったく指定しなかったときのデフォルト値の問題のようです。このデフォルト値は pytz が使用している IANA のタイムゾーンデータベースの次の部分から来ているそうです。

# Zone  NAME    GMTOFF  RULES FORMAT  [UNTIL]
Zone  Asia/Tokyo  9:18:59 - LMT 1887 Dec 31 15:00u
      9:00  Japan J%sT}
# Since 1938, all Japanese possessions have been like Asia/Tokyo.

確かに LMT でほぼ 9:19 ですね。

ちなみに、この 1887 年というのは、日本の標準時が明石に定められた 1888 年の前年です。 pytz.timezone('Asia/Tokyo') は、自動的に、 1887 年 12 月 31 日までは LMT 、 1888 年 1 月 1 日以降は JST になります。

import datetime
import pytz

pytz.timezone('Asia/Tokyo').localize(datetime.datetime(1880, 5, 27))
# => datetime.datetime(1880, 5, 27, 0, 0, tzinfo=<DstTzInfo 'Asia/Tokyo' LMT+9:19:00 STD>)

pytz.timezone('Asia/Tokyo').localize(datetime.datetime(2030, 5, 27))
# => datetime.datetime(2030, 5, 27, 0, 0, tzinfo=<DstTzInfo 'Asia/Tokyo' JST+9:00:00 STD>)

このあたりは IANA のタイムゾーンデータベースや pytz のバージョンが更新されると変わる可能性があるようです。私がこの挙動を確認したときの pytz のバージョンは 2018.4 です。

このあたりの詳細に興味のある方は次のページ等をご覧になってみてください。


LMT 9:19 の意味や日本標準時について知りたい方には次のページ等が参考になります。



余談終わり。

続いて今月と前月の最終日です。これらはシンプルなのでテスト無しで関数だけ書いておきます。

月の最終日を取得する

from delorean import Delorean

def get_last_day_of_month2(delorean):
    '''指定された日付の月の最終日を返す'''
    return delorean.truncate('month').next_month().last_day()

前月の最終日を取得する

from delorean import Delorean

def get_last_day_of_prev_month2(delorean):
    '''指定された日付の前月の最終日を返す'''
    return delorean.truncate('month').last_day()

Ruby の gem でよく見られるような直感的でわかりやすいインタフェースですね。

...

以上、月の初日や最終日を取得する方法についてでした。

このあたりは使うときにはよく使うパターンなので、必要なときにサッと書けるようにひきだしとして持っておくとよいかと思います。

参考

0 件のコメント: