kakakakakku blog

Weekly Tech Blog : Keep on Learning 👍

Playwright for Python: a タグの href 属性を取得する

Playwright for Python でリンクタグ a タグの href 属性から URL を取得するときは,Locator オブジェクトで get_attribute('href') のように実装する.

page.locator('h1#title > a').get_attribute('href')
page.locator('h1.entry-title > a').nth(0).get_attribute('href')

サンプルコード

以下の例では kakakakakku blog のブログ名と最新記事の URL を取得している.

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    context = browser.new_context()

    page = context.new_page()
    page.goto('https://kakakakakku.hatenablog.com/')

    # https://kakakakakku.hatenablog.com/
    url = page.locator('h1#title > a').get_attribute('href')
    print(url)

    # https://kakakakakku.hatenablog.com/entry/2023/01/10/081119
    url = page.locator('h1.entry-title > a').nth(0).get_attribute('href')
    print(url)

    context.close()
    browser.close()

Playwright for Python: GitHub Actions のエラー時にトレース情報を取得する

前回の記事では Playwright for Python の Trace Viewer を使って,ブラウザ操作中に「何が起きていたのか❓」を詳細に確認できることを紹介した(以下のリンク参照).さらに pytest 連携を使って,自動テスト実行中にトレース情報を取得できることも紹介した.今回は GitHub Actions と Trace Viewer と pytest を組み合わせて,CI 実行中にテストがエラーになったらトレース情報を GitHub Actions の「アーティファクト」に保存するサンプルを作る❗️

kakakakakku.hatenablog.com

GitHub Actions ワークフロー

以下のようにワークフローを作った.ポイントは pytest コマンドを実行するときに --tracing retain-on-failure を指定しているところと,if: failure() でテストがエラーになったときにトレース情報をアーティファクトに保存しているところ.他は自由に書き換えて使ってもらえれば〜

name: pytest

on:
  push:
    branches:
      - master
  pull_request:
    branches:
      - master

jobs:
  pytest:
    name: pytest
    runs-on: ubuntu-latest
    strategy:
      matrix:
        python-version: ['3.8', '3.9', '3.10']
    steps:
      - uses: actions/checkout@v3
      - name: Set up Python ${{ matrix.python-version }}
        uses: actions/setup-python@v4
        with:
          python-version: ${{ matrix.python-version }}
      - name: Install dependencies
        run: |
          pip install pytest pytest-playwright
          pip install -r requirements.txt
      - name: Set up Playwright
        run: |
          playwright install
      - name: Test with pytest
        run: |
          pytest --tracing retain-on-failure
      - name: Upload Trace
        if: failure()
        uses: actions/upload-artifact@v3
        with:
          name: playwright-trace ${{ matrix.python-version }}
          path: test-results/
          retention-days: 7

GitHub Actions ワークフローの構文に関しては以下のドキュメントなどを参考にした.

実際に 成功する pytest コード と エラーになる pytest コード 実行すると期待通りにアーティファクトを取得できた!

以下は取得したアーティファクト playwright-trace 3.10.zip を展開した trace.zip を playwright show-trace コマンドで表示したところ!ちゃんと見れてる〜

$ playwright show-trace ~/Downloads/test-playwright-py-test-playwright-chromium/trace.zip

Playwright for Python: ブラウザ操作のデバッグが捗る Trace Viewer

Playwright for Python の Trace Viewer を使うと,ブラウザ操作中に「何が起きていたのか❓」を詳細に探索できる.スクリーンショットや録画だけでは判断できないようなエラーをデバッグするときに使える.また pytest と連携させると自動テスト実行中にトレース情報を取得することもできて便利❗️今回は Trace Viewer を試す.

Trace Viewer を使う

ドキュメントに載っているサンプルコードを参考にしつつ,過去記事でも使ったコード(kakakakakku blog を開いて,キーワード kubernetes で記事を検索する)に Trace Viewer を組み込む.実装自体はとても簡単で,context オブジェクトで tracing.start() と tracing.stop() を実行する.たった2行❗️簡単〜

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    context = browser.new_context()

    context.tracing.start(screenshots=True, snapshots=True, sources=True)

    page = context.new_page()
    page.goto('https://kakakakakku.hatenablog.com/')

    page.get_by_placeholder('記事を検索').click()
    page.get_by_placeholder('記事を検索').fill('kubernetes')
    page.get_by_role('button', name='検索').click()
    page.wait_for_url('https://kakakakakku.hatenablog.com/search?q=kubernetes')

    context.tracing.stop(path = 'traces/trace.zip')

    context.close()
    browser.close()

コードを実行すると traces/trace.zip を取得できる.そして,playwright show-trace コマンドを実行するとトレースを確認するウィンドウが立ち上がる.もしくは「Trace Viewer ウェブサイト」に直接 ZIP ファイルをドラッグアンドドロップすることもできちゃう!

$ playwright show-trace traces/trace.zip

すると,Playwright for Python のアクションごとに「スクリーンショット」や「クリック位置」や「ネットワークリクエスト情報」を確認できる.時系列になっていて,デバッグが捗るぅ〜😁便利すぎる!

なお context.tracing.start(screenshots=True, snapshots=True, sources=True) のように実装した引数は大きく3種類ある.基本的には全部 True にしておけば良さそう.

  • screenshots: True or False(スクリーンショットを取得するかどうか)
  • snapshots: True or False(Playwright にクリックさせた位置などを取得するかどうか / Network を取得するかどうか)
  • sources: True or False(トレースとコードを紐付けるかどうか)

playwright.dev

しかし screenshots と snapshots を有効化すると ZIP ファイルのサイズがそこそこ大きくなってしまう.今回のシンプルな Playwright コードでも 4.3 MB だった.最終的にはデバッグをするときの情報量とのトレードオフになりそう.

screenshots snapshots sources ZIP size
True True True 4.3 MB
False True True 2.8 MB
False True False 2.8 MB
True False True 1.6 MB
True False False 1.6 MB
False False True 79 KB
False False False 78 KB

pytest 連携

Playwright for Python の pytest 連携を使うと,自動テスト実行中にトレース情報を取得できる.コマンドだと pytest --tracing を使う.引数は以下の3種類から選べるけど,エラー時にトレースを残す retain-on-failure を使うのが良さそう.Node.js だと再試行時にトレースを取得する on-first-retry も選べるけど,Python (pytest) だと選べなかった.

  • on
  • off
  • retain-on-failure

playwright.dev

実際に pytest --tracing retain-on-failure を実行してテストを落としたら,test-results/test-playwright-py-test-playwright-chromium/trace.zip を取得できるようになっていた.特に E2E テストは "Flaky(不安定になりがち)" だったりもするため,トレースを使って原因調査ができるのは便利!

$ pytest --tracing retain-on-failure
test_playwright.py
F                                                                                          [100%]

Playwright for Python 関連記事

kakakakakku.hatenablog.com

kakakakakku.hatenablog.com

kakakakakku.hatenablog.com

Textual がスゴイ!Python と CSS でストップウォッチアプリを作ってみた

Textual は Python を使って TUI (Text User Interfaces) アプリを構築できるフレームワークで,ドキュメントには "電卓" や "Color Picker" を実装したサンプルが載っている.アプリのデザインには CSS を使う.Python x CSS という技術スタックで,入門しやすくなっているのもメリットだと思う.

textual.textualize.io

Textual に入門するために Tutorial に載っている「ストップウォッチアプリ」を実装してみた.それぞれのストップウォッチで計測できて,ストップウォッチの追加/削除もできる.また dark モードと light モードを切り替えることもできる.簡単に実装できるし,何よりも楽しかった❗️Textual スゴイ✌️

また以下のギャラリー (Projects using Textual) を見ると,Textual を使って実装されたアプリが紹介されている.Kaskade(Kafka クライアント)や Tic-Tac-Toe(三目並べ)などなど.アプリの実装アイデアさえあったら何でも実装できそう.

www.textualize.io

Textual は The Changelog Podcast のエピソードを聴いて知った👂

changelog.com

Tutorial を試す

さっそく Tutorial を試す.Textual の機能や実装例を詳細に調べるためにはドキュメントを読む必要があるけど,Tutorial を試すと全体的な流れは理解できる.

textual.textualize.io

以下に最終的に実装できるストップウォッチアプリの stopwatch.py と stopwatch.css と載せておく.実際には Step ごとにコードを実装していくため,Tutorial も合わせて確認してもらえると良いかと!

stopwatch.py

from time import monotonic

from textual.app import App, ComposeResult
from textual.containers import Container
from textual.reactive import reactive
from textual.widgets import Button, Header, Footer, Static


class TimeDisplay(Static):
    """A widget to display elapsed time."""

    start_time = reactive(monotonic)
    time = reactive(0.0)
    total = reactive(0.0)

    def on_mount(self) -> None:
        """Event handler called when widget is added to the app."""
        self.update_timer = self.set_interval(1 / 60, self.update_time, pause=True)

    def update_time(self) -> None:
        """Method to update time to current."""
        self.time = self.total + (monotonic() - self.start_time)

    def watch_time(self, time: float) -> None:
        """Called when the time attribute changes."""
        minutes, seconds = divmod(time, 60)
        hours, minutes = divmod(minutes, 60)
        self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}")

    def start(self) -> None:
        """Method to start (or resume) time updating."""
        self.start_time = monotonic()
        self.update_timer.resume()

    def stop(self):
        """Method to stop the time display updating."""
        self.update_timer.pause()
        self.total += monotonic() - self.start_time
        self.time = self.total

    def reset(self):
        """Method to reset the time display to zero."""
        self.total = 0
        self.time = 0


class Stopwatch(Static):
    """A stopwatch widget."""

    def on_button_pressed(self, event: Button.Pressed) -> None:
        """Event handler called when a button is pressed."""
        button_id = event.button.id
        time_display = self.query_one(TimeDisplay)
        if button_id == "start":
            time_display.start()
            self.add_class("started")
        elif button_id == "stop":
            time_display.stop()
            self.remove_class("started")
        elif button_id == "reset":
            time_display.reset()

    def compose(self) -> ComposeResult:
        """Create child widgets of a stopwatch."""
        yield Button("Start", id="start", variant="success")
        yield Button("Stop", id="stop", variant="error")
        yield Button("Reset", id="reset")
        yield TimeDisplay()


class StopwatchApp(App):
    """A Textual app to manage stopwatches."""

    CSS_PATH = "stopwatch.css"

    BINDINGS = [
        ("d", "toggle_dark", "Toggle dark mode"),
        ("a", "add_stopwatch", "Add"),
        ("r", "remove_stopwatch", "Remove"),
    ]

    def compose(self) -> ComposeResult:
        """Called to add widgets to the app."""
        yield Header()
        yield Footer()
        yield Container(Stopwatch(), Stopwatch(), Stopwatch(), id="timers")

    def action_add_stopwatch(self) -> None:
        """An action to add a timer."""
        new_stopwatch = Stopwatch()
        self.query_one("#timers").mount(new_stopwatch)
        new_stopwatch.scroll_visible()

    def action_remove_stopwatch(self) -> None:
        """Called to remove a timer."""
        timers = self.query("Stopwatch")
        if timers:
            timers.last().remove()

    def action_toggle_dark(self) -> None:
        """An action to toggle dark mode."""
        self.dark = not self.dark


if __name__ == "__main__":
    app = StopwatchApp()
    app.run()

stopwatch.css

Stopwatch {
    layout: horizontal;
    background: $boost;
    height: 5;
    margin: 1;
    min-width: 50;
    padding: 1;
}

TimeDisplay {
    content-align: center middle;
    text-opacity: 60%;
    height: 3;
}

Button {
    width: 16;
}

#start {
    dock: left;
}

#stop {
    dock: left;
    display: none;
}

#reset {
    dock: right;
}

.started {
    text-style: bold;
    background: $success;
    color: $text;
}

.started TimeDisplay {
    text-opacity: 100%;
}

.started #start {
    display: none
}

.started #stop {
    display: block
}

.started #reset {
    visibility: hidden
}

Step.1: stopwatch01.py

Step.1 では,アプリの「雛形」を作る.Textual では App class を継承してアプリを実装するため,コードでは StopwatchApp class を実装して,最後に run() を実行している.また,Textual よく使うコンポーネントは「ウィジェット」としてプリセットされていて,今回は Header(画面上部のバー) と Footer(画面下部のバー)を組み込んでいる.ウェジェットには他にも Checkbox や DataTable などもある.

またフッターに Toggle dark mode と表示されている.Textual では BINDINGS = [("d", "toggle_dark", "Toggle dark mode")] のように BINDINGS を設定すると,簡単に「キーバインド」を実装できる.今回は d キーを押すと「dark モード / light モード」を切り替えられる.

Step.2: stopwatch02.py

Step.2 では,ストップウォッチアプリに必要なコンポーネントとして「ボタン」と「タイマー」を実装する.今回は Start / Stop / Reset ボタンと時間を表示する Stopwatch class を実装して,UI を構成する compose 関数の中で yield Container(Stopwatch(), Stopwatch(), Stopwatch()) のように3個並べるように組み込んでいる.実際にキャプチャを見ると「縦に」並んでいる.

Step.3: stopwatch03.py

Step.3 では,アプリのデザインを設定する.最初は驚いたけど,Textual では Python と CSS を組み合わせられるようになっている.StopwatchApp class に CSS_PATH = "stopwatch03.css" のように CSS ファイルを指定できる.CSS を適用すると,キャプチャのように「横に」並んだ.また計測をする前には不要な Stop ボタンも display: none; で非表示になっている.

Step.4: stopwatch04.py

Step.4 では,Start ボタンを押したときに Stop ボタンを表示できるようにする.「ボタンを押した」というイベントは on_button_pressed() 関数で検知できて,また add_class() 関数や remove_class() 関数を使って CSS のクラスも制御できる.結果的にストップウォッチアプリとして使えるように Start / Stop / Reset ボタンの可視制御を行えるようになった.

class Stopwatch(Static):
    """A stopwatch widget."""

    def on_button_pressed(self, event: Button.Pressed) -> None:
        """Event handler called when a button is pressed."""
        if event.button.id == "start":
            self.add_class("started")
        elif event.button.id == "stop":
            self.remove_class("started")

Step.5: stopwatch05.py

Step.5 では,Textual の「リアクティブ機能」を使う.reactive class を使って値を初期化すると,値の変化を監視できるようになる.監視する場合は watch_xxx という関数を実装する必要があって,今回は watch_time() 関数を実装している.そして,on_mount 関数でアプリ開始時の初期化実装ができる.最終的にストップウォッチで経過した時間を表示できるようになる.Step.5 時点では,アプリの起動と同時に計測が開始される.

class TimeDisplay(Static):
    """A widget to display elapsed time."""

    start_time = reactive(monotonic)
    time = reactive(0.0)

    def on_mount(self) -> None:
        """Event handler called when widget is added to the app."""
        self.set_interval(1 / 60, self.update_time)

    def update_time(self) -> None:
        """Method to update the time to the current time."""
        self.time = monotonic() - self.start_time

    def watch_time(self, time: float) -> None:
        """Called when the time attribute changes."""
        minutes, seconds = divmod(time, 60)
        hours, minutes = divmod(minutes, 60)
        self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}")

Step.6: stopwatch06.py

Step.6 では,Start ボタンを押したら計測を開始できるように実装する.TimeDisplay class に start() / stop() / reset() 関数をそれぞれ追加して,ボタンを押したら関数を実行するように連携する.すると,ストップウォッチごとに(現状だと3個)計測できるようになる.スゴイ❗️もうストップウォッチアプリを実装できちゃった (>ω<)

class TimeDisplay(Static):
    """A widget to display elapsed time."""

    start_time = reactive(monotonic)
    time = reactive(0.0)
    total = reactive(0.0)

    def on_mount(self) -> None:
        """Event handler called when widget is added to the app."""
        self.update_timer = self.set_interval(1 / 60, self.update_time, pause=True)

    def update_time(self) -> None:
        """Method to update time to current."""
        self.time = self.total + (monotonic() - self.start_time)

    def watch_time(self, time: float) -> None:
        """Called when the time attribute changes."""
        minutes, seconds = divmod(time, 60)
        hours, minutes = divmod(minutes, 60)
        self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}")

    def start(self) -> None:
        """Method to start (or resume) time updating."""
        self.start_time = monotonic()
        self.update_timer.resume()

    def stop(self):
        """Method to stop the time display updating."""
        self.update_timer.pause()
        self.total += monotonic() - self.start_time
        self.time = self.total

    def reset(self):
        """Method to reset the time display to zero."""
        self.total = 0
        self.time = 0

完成すると: stopwatch.py

最終的に完成すると,a キーと r キーを押すとストップウォッチ(デフォルト3個)を増やしたり減らしたりできる.Container class を使って組み込んだ UI を「動的に変更できること」は覚えておくと良さそう.

class StopwatchApp(App):
    """A Textual app to manage stopwatches."""

    CSS_PATH = "stopwatch.css"

    BINDINGS = [
        ("d", "toggle_dark", "Toggle dark mode"),
        ("a", "add_stopwatch", "Add"),
        ("r", "remove_stopwatch", "Remove"),
    ]

    def compose(self) -> ComposeResult:
        """Called to add widgets to the app."""
        yield Header()
        yield Footer()
        yield Container(Stopwatch(), Stopwatch(), Stopwatch(), id="timers")

    def action_add_stopwatch(self) -> None:
        """An action to add a timer."""
        new_stopwatch = Stopwatch()
        self.query_one("#timers").mount(new_stopwatch)
        new_stopwatch.scroll_visible()

    def action_remove_stopwatch(self) -> None:
        """Called to remove a timer."""
        timers = self.query("Stopwatch")
        if timers:
            timers.last().remove()

まとめ

Python と CSS を組み合わせて TUI アプリを実装できるフレームワーク Textual を試した.Tutorial に沿って進めると,簡単に「ストップウォッチアプリ」を実装できて楽しかった❗️個人的に「Tomato 2」を使ってポモドーロを回しているけど,今度 Textual を使ってポモドーロアプリを自作してみるのも良さそう.Textual 本当にスゴイから引き続き使っていくぞー✌️

kakakakakku.hatenablog.com

Playwright for Python: iPhone / iPad / Pixel のブラウザ操作を自動化する

Playwright for Python を使うと iPhone / iPad / Pixel などのデバイスを使ったブラウザ操作を自動化できる.

ドキュメントに書いてある通り,playwright.devices の Dict(辞書)にデバイス名を設定して,browser.new_context() でコンテキストを作る.サンプルコードは後半に載せておく.簡単に自動化できるぞー❗️

playwright.dev

コードを書きながら「デバイス名」をドキュメントから発見できず困ったけど,Playwright for Python でサポートしているデバイス名は GitHub の deviceDescriptorsSource.json で確認できた.機種的には少し古い感じもするけど,iPhone / iPad / Pixel 以外に BlackBerry / Galaxy / Nexus なども選べるようになっていた.

github.com

iPhone 13 Pro

Apple 系のデバイス一覧を以下に挙げた.landscape は「横置き」を意味する.

  • iPad (gen 6)
  • iPad (gen 6) landscape
  • iPad (gen 7)
  • iPad (gen 7) landscape
  • iPad Mini
  • iPad Mini landscape
  • iPad Pro 11
  • iPad Pro 11 landscape
  • iPhone 6
  • iPhone 6 landscape
  • iPhone 6 Plus
  • iPhone 6 Plus landscape
  • iPhone 7
  • iPhone 7 landscape
  • iPhone 7 Plus
  • iPhone 7 Plus landscape
  • iPhone 8
  • iPhone 8 landscape
  • iPhone 8 Plus
  • iPhone 8 Plus landscape
  • iPhone SE
  • iPhone SE landscape
  • iPhone X
  • iPhone X landscape
  • iPhone XR
  • iPhone XR landscape
  • iPhone 11
  • iPhone 11 landscape
  • iPhone 11 Pro
  • iPhone 11 Pro landscape
  • iPhone 11 Pro Max
  • iPhone 11 Pro Max landscape
  • iPhone 12
  • iPhone 12 landscape
  • iPhone 12 Pro
  • iPhone 12 Pro landscape
  • iPhone 12 Pro Max
  • iPhone 12 Pro Max landscape
  • iPhone 12 Mini
  • iPhone 12 Mini landscape
  • iPhone 13
  • iPhone 13 landscape
  • iPhone 13 Pro
  • iPhone 13 Pro landscape
  • iPhone 13 Pro Max
  • iPhone 13 Pro Max landscape
  • iPhone 13 Mini
  • iPhone 13 Mini landscape

まずは「iPhone 13 Pro」を試す.iPhone の場合はブラウザタイプに webkit を選ぶ.

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.webkit.launch()
    device = p.devices['iPhone 13 Pro']
    context = browser.new_context(**device)

    page = context.new_page()
    page.goto('https://playwright.dev/python/')
    page.screenshot(path='images/playwright-device-iphone.png')

    context.close()
    browser.close()

スクリーンショットを載せておく📷

iPad Pro 11 landscape

次に「iPad Pro 11 landscape(横置き)」を試す.iPad の場合もブラウザタイプに webkit を選ぶ.

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.webkit.launch()
    device = p.devices['iPad Pro 11 landscape']
    context = browser.new_context(**device)

    page = context.new_page()
    page.goto('https://playwright.dev/python/')
    page.screenshot(path='images/playwright-device-ipad.png')

    context.close()
    browser.close()

スクリーンショットを載せておく📷

Pixel 5

Pixel 系のデバイス一覧を以下に挙げた.

  • Pixel 2
  • Pixel 2 landscape
  • Pixel 2 XL
  • Pixel 2 XL landscape
  • Pixel 3
  • Pixel 3 landscape
  • Pixel 4
  • Pixel 4 landscape
  • Pixel 4a (5G)
  • Pixel 4a (5G) landscape
  • Pixel 5
  • Pixel 5 landscape

最後は「Pixel 5」を試す.Pixel の場合はブラウザタイプに chromium を選ぶ.

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    device = p.devices['Pixel 5']
    context = browser.new_context(**device)

    page = context.new_page()
    page.goto('https://playwright.dev/python/')
    page.screenshot(path='images/playwright-device-pixel.png')

    context.close()
    browser.close()

スクリーンショットを載せておく📷

関連記事

kakakakakku.hatenablog.com

kakakakakku.hatenablog.com