whoisパッケージに対してpatchを適用する

9 min read読了の目安(約8100字

環境

  • ubuntu 20.04
  • python 3.8.5
  • poetry 1.1.4

whoisパッケージをインストール

Ubuntuサーバにwhoisパッケージをインストールする。
今回使用するパッケージは、以下である。

https://pypi.org/project/whois/

ちなみにこのパッケージのGuthubはこちら↓

https://github.com/DannyCork/python-whois

今回は、仮想環境作成ツールpoetryを用いて作っておいた環境にて、
該当パッケージをインストールする。

$ poetry add whois

加えて、上記のpython用ラッパーであるwhoisは、Linuxコマンドのwhoisを使用するので、
まだUbuntuに入っていない場合は、aptにてインストールしてあげる。

$ apt install whois

whoisパッケージの動作確認

以下の様に、例えばgoogle.comのwhois情報を取得してみる。

$ poetry run ipython
Python 3.8.5 (default, Jul 28 2020, 12:59:40) 
Type 'copyright', 'credits' or 'license' for more information
IPython 7.19.0 -- An enhanced Interactive Python. Type '?' for help.

In [1]: import whois

In [2]: result = whois.query('google.com')

In [3]: result
Out[3]: <whois._3_adjust.Domain at 0x7fe43629af10>

In [4]: result.__dict__
Out[4]: 
{'name': 'google.com',
 'registrar': 'MarkMonitor Inc.',
 'creation_date': datetime.datetime(1997, 9, 15, 4, 0),
 'expiration_date': datetime.datetime(2028, 9, 14, 4, 0),
 'last_updated': None,
 'status': 'clientDeleteProhibited https://icann.org/epp#clientDeleteProhibited',
 'name_servers': {'ns1.google.com',
  'ns2.google.com',
  'ns3.google.com',
  'ns4.google.com'}}

patchを適用

whoisデータは、管理サーバによってレスポンスがばらばらであるため、
プログラム側でレスポンスデータのパース処理エラーによる処理停止につながらないように、
どういったデータが返ってきても対応できるようにしたい。

そのために、python用ラッパーであるwhois側のスクリプトにpatchを適用することで、
レスポンスデータに対応できるようにする。

基本スクリプトの作成

試しに以下の様な基本的なスクリプトを作成しておく。

import whois
import argparse


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("-d", "--domain",
                        help="specify a domain.",
                        required=True)
    args = parser.parse_args()

    r = whois.query(args.domain)
    print(r.__dict__)


if __name__ == "__main__":
    main()

このスクリプトは、コマンドラインから受け取ったドメインに対するWhois情報を取得し、
標準出力してくれるというものである。

実行例は以下のようになる。

$ poetry run python app.py -d google.com
{'name': 'google.com', 'registrar': 'MarkMonitor Inc.', 'creation_date': datetime.datetime(1997, 9, 15, 4, 0), 'expiration_date': datetime.datetime(2028, 9, 14, 4, 0), 'last_updated': None, 'status': 'clientDeleteProhibited https://icann.org/epp#clientDeleteProhibited', 'name_servers': {'ns4.google.com', 'ns3.google.com', 'ns2.google.com', 'ns1.google.com'}}

しかし、TLD次第では、レスポンスをパースできずに失敗するケースがある。
例えば以下のようになる。

$ poetry run python app.py -d ox.ac.uk
Traceback (most recent call last):
  File "app.py", line 17, in <module>
    main()
  File "app.py", line 12, in main
    r = whois.query(args.domain)
  File "/home/massu2357/develop/python_pract/.venv/lib/python3.8/site-packages/whois/__init__.py", line 73, in query
    return Domain(pd) if pd['domain_name'][0] else None
  File "/home/massu2357/develop/python_pract/.venv/lib/python3.8/site-packages/whois/_3_adjust.py", line 14, in __init__
    self.creation_date = str_to_date(data['creation_date'][0])
  File "/home/massu2357/develop/python_pract/.venv/lib/python3.8/site-packages/whois/_3_adjust.py", line 107, in str_to_date
    raise UnknownDateFormat("Unknown date format: '%s'" % text)
whois.exceptions.UnknownDateFormat: Unknown date format: 'wednesday 17th september 2003'

そのため、この基本スクリプトをベースに、whoisパッケージのモジュールにpatchを適用する。

patch適用箇所の確認

先ほどのスタックトレースを確認すると、まず最初に以下のようになっている。

  File "/home/massu2357/develop/python_pract/.venv/lib/python3.8/site-packages/whois/__init__.py", line 73, in query
    return Domain(pd) if pd['domain_name'][0] else None

ここでは、__init__.py73行目が指摘されている。
なので一度周辺コードを確認してみる。

whois/__init__.py

    while 1:
        pd = do_parse(do_query(d, force, cache_file, slow_down, ignore_returncode), tld)
        if (not pd or not pd['domain_name'][0]) and len(d) > 2:
            d = d[1:]
        else:
            break

    return Domain(pd) if pd['domain_name'][0] else None   # 73 行目

周辺コードを確認すると、まず、与えたドメインに対する処理を施し、
結果をpdに格納している。

次に、スタックトレースの以下の箇所を確認する。

  File "/home/massu2357/develop/python_pract/.venv/lib/python3.8/site-packages/whois/_3_adjust.py", line 14, in __init__
    self.creation_date = str_to_date(data['creation_date'][0])

この_3_adjust.pyでは、Whoisデータを格納するための専用クラスが定義されている。
この専用クラスは、Domainである。

__init__.py73行目で示されているように、ドメインに対するWhoisデータの結果pdは、
構造化させるためにDomainクラスでインスタンス化している。

よって、Domainクラスをみる。

スタックトレースにて、_3_adjust.py14行目と指摘されているので、
こちらを確認する。

whois/_3_adjust.py

class Domain:

    def __init__(self, data):
        self.name = data['domain_name'][0].strip().lower()
        self.registrar = data['registrar'][0].strip()
        self.creation_date = str_to_date(data['creation_date'][0])   # 14 行目
        self.expiration_date = str_to_date(data['expiration_date'][0])
        self.last_updated = str_to_date(data['updated_date'][0])
        self.status = data['status'][0].strip()

さらにスタックトレースの以下の指摘をみる。
_3_adjust.py107行目と指摘されている。

  File "/home/massu2357/develop/python_pract/.venv/lib/python3.8/site-packages/whois/_3_adjust.py", line 107, in str_to_date
    raise UnknownDateFormat("Unknown date format: '%s'" % text)
whois.exceptions.UnknownDateFormat: Unknown date format: 'wednesday 17th september 2003'

whois/_3_adjust.py

def str_to_date(text):
    text = text.strip().lower()

    if not text or text == 'not defined':
        return

    text = text.replace('(jst)', '(+0900)')
    text = re.sub('(\+[0-9]{2}):([0-9]{2})', '\\1\\2', text)
    text = re.sub('(\ #.*)', '', text)

    if PYTHON_VERSION < 3:
        return str_to_date_py2(text)

    for format in DATE_FORMATS:
        try:
            return datetime.datetime.strptime(text, format)
        except ValueError:
            pass

    raise UnknownDateFormat("Unknown date format: '%s'" % text)   # 107 行目

スタックトレースの内容を踏まえると、今回与えたドメインのWhoisデータでは、
Dateのフォーマットが構造化できないものであったことが分かる。

そのため、UnknownDateFormatraiseされ、例外エラーとなっている。

では今回は、Dateの構造化失敗で処理が停止しないようにpatchを適用したい。

patchの適用処理

Dateの構造化処理を行なっている以下の関数を、そのまま独自の関数に置き換えてみる。

whois/_3_adjust.py

def str_to_date(text):
    text = text.strip().lower()

    if not text or text == 'not defined':
        return

    text = text.replace('(jst)', '(+0900)')
    text = re.sub('(\+[0-9]{2}):([0-9]{2})', '\\1\\2', text)
    text = re.sub('(\ #.*)', '', text)

    if PYTHON_VERSION < 3:
        return str_to_date_py2(text)

    for format in DATE_FORMATS:
        try:
            return datetime.datetime.strptime(text, format)
        except ValueError as e:
            pass

    raise UnknownDateFormat("Unknown date format: '%s'" % text)

基本スクリプトを以下の様に修正する。

import whois
import argparse
import re
import datetime
from whois._3_adjust import DATE_FORMATS


def main():
    parser = argparse.ArgumentParser()
    parser.add_argument("-d", "--domain",
                        help="specify a domain.",
                        required=True)
    args = parser.parse_args()

    # patch適用(自身の関数でMockする)
    whois._3_adjust.str_to_date = patched_str_to_date

    r = whois.query(args.domain)
    print(r.__dict__)


# patch適用のための関数
def patched_str_to_date(text):
    text = text.strip().lower()

    if not text or text == 'not defined':
        return

    text = text.replace('(jst)', '(+0900)')
    text = re.sub('(\+[0-9]{2}):([0-9]{2})', '\\1\\2', text)
    text = re.sub('(\ #.*)', '', text)

    for format in DATE_FORMATS:
        try:
            return datetime.datetime.strptime(text, format)
        except ValueError:
            pass

    print("Unknown date format: '%s'" % text)
    return text


if __name__ == "__main__":
    main()

今回は、Date型のフォーマットに変換できないような文字列だった場合は、
そのままオリジナルの文字列を返すようにした。

これで実行を行うと、Unknown data formatではあるが、
処理が停止せずに結果を取得できている。

$ poetry run python app.py -d ox.ac.uk
Unknown date format: 'wednesday 17th september 2003'
Unknown date format: 'tuesday 26th jul 2022'
Unknown date format: 'sunday 26th april 2020'
{'name': 'ox.ac.uk', 'registrar': 'University of Oxford', 'creation_date': 'wednesday 17th september 2003', 'expiration_date': 'tuesday 26th jul 2022', 'last_updated': 'sunday 26th april 2020', 'status': '', 'name_servers': {'dns0.ox.ac.uk\t129.67.1.190'}, 'owner': 'University of Oxford'}