親クラスの位置引数(positional argument)が子クラスで迷子になっちゃう件。

※ 2020/02/21 色々説明不足な点を追記
昔血迷ってただけの文章なので優しい目で読んで下さい。

きっかけは。

まだそんなに開発が進んでいないが、とっても気になるリポジトリを見つけた。

github.com

scrapy-selenium。要するに、Scrapy上でSeleniumを使用できるらしい。
これのイイところは、Downloader MiddlewareでSeleniumの設定ができるようにしようとしてるところ。
「できるようにしている」と表現したのは、まだwait_timescreen_shotくらいしか実装されていないから。設計自体はなかなか良さそうなので、これを試してみようとした。

scrapyのRequestモジュールと同じ要領でhttpRequestを送ろうとしたところ、、

エラッた。

from scrapy_selenium import SeleniumRequest

yield SeleniumRequest("https://www.kimoton.com/")

Traceback (most recent call last):
  File "/home/kimoton/.pyenv/versions/3.6.5/lib/python3.6/site-packages/scrapy/core/engine.py", line 127, in _next_request
    request = next(slot.start_requests)
  File "/home/kimoton/project/private/FlightScraper/FlightScraper/FlightScraper/spiders/toscrape_xpath.py", line 14, in start_requests
    self.parse_result,
  File "/home/kimoton/.pyenv/versions/3.6.5/lib/python3.6/site-packages/scrapy_selenium/http.py", line 29, in __init__
    super().__init__(*args, **kwargs)
TypeError: __init__() missing 1 required positional argument: 'url'

このエラーが、なかなか面白かった。

エラーが出たクラス

エラーの根源は、以下のSeleniumRequestクラスの初期化関数であった。

class SeleniumRequest(Request):
    def __init__(self, wait_time=None, wait_until=None, screenshot=False, *args, **kwargs):
        self.wait_time = wait_time
        self.wait_until = wait_until
        self.screenshot = screenshot

        super().__init__(*args, **kwargs)

ここでは、可変長引数を使って、親クラスであるRequestクラスの初期化関数(__init__())で使用されている引数を受けられるようにしていた。 つまり開発者は、Requestクラスと同じように扱えるようにしようと、そう思っていたはず。。

しかし、ここには決定的なミスがあった(追記:敢えてそうしたのかも)。

親クラスのscrapy.http.Requestクラスを見てみよう。

親クラスのRequestクラス

class Request(object_ref):

    def __init__(self, url, callback=None, method='GET', headers=None, body=None,
                 cookies=None, meta=None, encoding='utf-8', priority=0,
                 dont_filter=False, errback=None, flags=None):

        self._encoding = encoding  # this one has to be set first
        self.method = str(method).upper()
        self._set_url(url)
        self._set_body(body)
        assert isinstance(priority, int), "Request priority not an integer: %r" % priority
        self.priority = priority

        if callback is not None and not callable(callback):
            raise TypeError('callback must be a callable, got %s' % type(callback).__name__)
        if errback is not None and not callable(errback):
            raise TypeError('errback must be a callable, got %s' % type(errback).__name__)
        assert callback or not errback, "Cannot use errback without a callback"
        self.callback = callback
        self.errback = errback

        self.cookies = cookies or {}
        self.headers = Headers(headers or {}, encoding=encoding)
        self.dont_filter = dont_filter

        self._meta = dict(meta) if meta else None
        self.flags = [] if flags is None else list(flags)

着目してほしいのは、url引数が、位置引数(positional argument)として渡されている点だ。

これを子クラスで使用したい場合、子クラスのキーワード引数の後で*argsとして与えると位置引数として与えられなくなる。

class SeleniumRequest(Request):
    def __init__(self, wait_time=None, wait_until=None, screenshot=False, *args, **kwargs):
        self.wait_time = wait_time
        self.wait_until = wait_until
        self.screenshot = screenshot

        super().__init__(*args, **kwargs)

追記:敢えてこうすることによって明示的に引数定義するようにユーザーに仕向けられるというメリットがある。

簡単なクラスでテスト

挙動を再確認するため、簡単なクラスを作成する。

親クラス(parent

class parent():
    def __init__(self, one, two=None, three=None):
        self.one = one
        self.two = two
        self.three = three

子クラス(child

class child(parent):
    def __init__(self, child_one="child_one", child_two="child_two", *args, **kwargs):
        self.child_one = child_one
        self.child_two = child_two
        super().__init__(*args, **kwargs)

として、

>>> c = child("one")
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "<stdin>", line 5, in __init__
TypeError: __init__() missing 1 required positional argument: 'one'

親クラスの位置引数(one)が与えられてないよって怒られます。
与えているのに。

>>> c = child(one="one")
>>> c.one
'one'

キーワード引数として与えてあげると問題なく読み込めます。

結局どうすればよかったのか

親クラスと同じような引数指定にしたかったら、これでいいんでないかな。。

class SeleniumRequest(Request):
    def __init__(self, url, wait_time=None, wait_until=None, screenshot=False, **kwargs):
        self.wait_time = wait_time
        self.wait_until = wait_until
        self.screenshot = screenshot

        super().__init__(url, **kwargs)