kakakakakku blog

Weekly Tech Blog: Keep on Learning!

暫定対応 : Python 3.10 で python-pptx を使おうとすると collections で AttributeError が出てしまう

「python-pptx」は Python で Microsoft PowerPoint ファイルを操作できるライブラリでよく使っている❗️

github.com

最近 Python 3.10(今回の環境だと 3.10.8)で python-pptx を使おうと思ったら,以下のように AttributeError: module 'collections' has no attribute 'abc'AttributeError: module 'collections' has no attribute 'Container' というエラーが出てしまった🔥暫定対応をまとめておく.

$ python --version
Python 3.10.8

$ python ~/python-pptx.py
Traceback (most recent call last):
  File "/usr/local/lib/python3.10/site-packages/pptx/compat/__init__.py", line 10, in <module>
    Container = collections.abc.Container
AttributeError: module 'collections' has no attribute 'abc'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/Users/kakakakakku/python-pptx.py", line 1, in <module>
    from pptx import Presentation
  File "/usr/local/lib/python3.10/site-packages/pptx/__init__.py", line 14, in <module>
    from pptx.api import Presentation  # noqa
  File "/usr/local/lib/python3.10/site-packages/pptx/api.py", line 15, in <module>
    from .package import Package
  File "/usr/local/lib/python3.10/site-packages/pptx/package.py", line 6, in <module>
    from pptx.opc.package import OpcPackage
  File "/usr/local/lib/python3.10/site-packages/pptx/opc/package.py", line 11, in <module>
    from pptx.compat import is_string, Mapping
  File "/usr/local/lib/python3.10/site-packages/pptx/compat/__init__.py", line 14, in <module>
    Container = collections.Container
AttributeError: module 'collections' has no attribute 'Container'

理由としては collections は Python 3.3 で非推奨になって,Python 3.9 までは使えるようになっていた.しかし Python 3.10 ではサポートされず,collections.abc を使う必要がある.python-pptx では collections に依存する箇所があって Python 3.10 で動かなくなっている.

Deprecated since version 3.3, will be removed in version 3.10: Moved Collections Abstract Base Classes to the collections.abc module. For backwards compatibility, they continue to be visible in this module through Python 3.9.
collections — Container datatypes — Python 3.9.14 documentation

暫定対応

現時点(2022年11月末)でリリースされている python-pptx の最新バージョン 0.6.21 では Python 3.10 に対応してなく,暫定対応(ワークアラウンド)としてコードを直接修正する必要がある.以下のように pip show コマンドを使ってコードディレクトリを探して,pptx/compat/__init__.py を修正する.今回は /usr/local/lib/python3.10/site-packages/pptx/compat/__init__.py にあった.

$ pip show python-pptx | grep Location
Location: /usr/local/lib/python3.10/site-packages

そして pptx/compat/__init__.py の前半を以下のように修正する.

import sys

try:
    import collections.abc
    Container = collections.abc.Container
    Mapping = collections.abc.Mapping
    Sequence = collections.abc.Sequence
except ImportError:
    import collections
    Container = collections.Container
    Mapping = collections.Mapping
    Sequence = collections.Sequence

実際に python-pptx にプルリクエストも出ているし,Approved も付いているし,merge されるとイイなぁー❗️

github.com

もしくは pptx/compat/__init__.py は修正せずに,以下のように Python コードに直接 import collections.abc を書いてしまう案もある.その場合には順番に注意すること👀

import collections.abc
from pptx import Presentation

関連 issue

Playwright for Python: ブラウザ操作を録画する

Playwright for Python を使うとブラウザ操作を簡単に録画できる.

ドキュメントに書いてある通り,browser.new_context() でコンテキストを作るときに以下の video 関連のパラメータを設定する.注意点は録画データを最後まで保存するために context.close() を必ず実行するところ!

  • record_video_dir: 録画を有効化する(保存するディレクトリを指定する)
  • record_video_size: サイズを指定する(デフォルトは 800x450 になる)

playwright.dev

以下のコードでは kakakakakku blog を開いて,キーワード kubernetes で記事を検索している.

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    context = browser.new_context(
        record_video_dir='videos/',
        record_video_size={'width': 800, 'height': 450}
    )
    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.close()
    browser.close()

Playwright for Python で録画をすると .webm 拡張子で録画データが生成される.以下は WebM を Gif に変換して載せているので荒くなっているけど,検索しているブラウザ操作を期待通りに録画できている.簡単!便利!

関連記事

少し前に Playwright for Python に入門した記事も載せておく❗️

kakakakakku.hatenablog.com

UUID v6, v7, v8 : タイムスタンプでソートできる新しい UUID のドラフト仕様

ID を採番するときによく使われる UUID Version 4 の課題として「順序性がなくソートしにくい」という側面があり,ULID (Universally Unique Lexicographically Sortable Identifier) を使えばソートできるようになるという記事を前に書いた.

kakakakakku.hatenablog.com

関連して調査をしていたら,標準化団体 IETF (Internet Engineering Task Force) によって,UUID Version 6, 7, 8 という新しい仕様が提案(ドラフト段階)されていることを発見した❗️UUID Version 6, 7, 8 の目的を簡単にまとめると「タイムスタンプ情報を使ってソートできる ID を採番できるようにする」となり,もしこの仕様が取り込まれると,UUID を活用する幅がさらに広がりそう.

  • UUID Version v6: グレゴリオ暦ベース(UUID Version v1 の改善)
  • UUID Version v7: Unix Time Stamp ベース
  • UUID Version v8: 独自仕様(実験的もしくはベンダー固有の要件で使う)

詳細な仕様は以下に載っている.記事を書いている2022年10月時点ではまだドラフト段階なので,変わる可能性はある!

www.ietf.org

github.com

uuid6/prototypes リポジトリ

GitHub の uuid6/prototypes リポジトリを見ると,いろいろな言語で実装された UUID Version 6, 7, 8 用のライブラリが載っている.

github.com

今回は UUID Version v6UUID Version v7 を試すため,Pyhon で最新ドラフト (04) までをサポートしている uuid6 を使う.pip で簡単にセットアップできる.なお,名前は uuid6 だけど,実装としては UUID Version v6 と UUID Version v7 どちらも試せる.

github.com

UUID Version 6 (UUID v6)

ドラフト仕様を読むと,UUID v6UUID v1 を改善するために策定された仕様で,UUID v1 のフィールドの構成を見直したものになる.UUID v1 は「タイムスタンプ情報」を持つけど,最下位部分と上位部分が逆転していて,ソートはできない.UUID v6 はあくまで UUID v1 の課題を解決したものとなり,基本的には UUID v7 を使うとドラフト仕様に書いてある.

UUID v6 のタイムスタンプ情報は UUID v1 と同じく「グレゴリオ暦ベース (60 bit)」で,1582年10月15日からの 100 ns 単位のカウントとなる.記事を書きながら採番した UUID v6 の例を以下に載せておく.

  • 1ed575d3-b55c-6f9c-93a7-e81a4645882c
  • 1ed575d3-c876-609c-9270-7532b69c8a8e
  • 1ed575d3-db8a-669c-8634-8a0ba964ffa3

UUID Version 1 is a time-based UUID featuring a 60-bit timestamp represented by Coordinated Universal Time (UTC) as a count of 100- nanosecond intervals since 00:00:00.00, 15 October 1582 (the date of Gregorian reform to the Christian calendar).

UUID v6 のフィールド構成は 128 bit 長で以下の通り.より詳細な表現はドラフト仕様を参照で!なお,ID の中に「バージョン情報」も含まれるため,3 ブロック目の1文字目は必ず 6 になる.

  • time_high: タイムスタンプ情報の最上位 32 bit
  • time_mid: タイムスタンプ情報の中間 16 bit
  • time_low_and_version: バージョン情報 4 bit とタイムスタンプ情報の最下位 12 bit
  • clk_seq_hi_res: UUID variant 10 2 bit とクロックシーケンス情報の上位 6 bit など
  • clk_seq_low: クロックシーケンス情報の下位 8 bit
  • node: 一意の識別子 48 bit
 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                           time_high                           |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|           time_mid            |      time_low_and_version     |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|clk_seq_hi_res |  clk_seq_low  |         node (0-1)            |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                         node (2-5)                            |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

UUID v6 を Python で試す

uuid6 ライブラリを使うと簡単に UUID v6 で採番できる.以下では2秒間隔で「3種類」の id を採番して,期待通りに比較できている.比較できるということはソートもできる!

from uuid6 import uuid6
import time

# 1ed575d3-b55c-6f9c-93a7-e81a4645882c
id1 = uuid6()
print(id1)

time.sleep(2)

# 1ed575d3-c876-609c-9270-7532b69c8a8e
id2 = uuid6()
print(id2)

time.sleep(2)

# 1ed575d3-db8a-669c-8634-8a0ba964ffa3
id3 = uuid6()
print(id3)

# 比較
print(id1 < id2)  # True
print(id2 < id3)  # True
print(id3 < id1)  # False

UUID Version 7 (UUID v7)

ドラフト仕様を読むと,UUID v7 はよく使われる Unix Time (32 bit) にミリ秒を追加した 48 bit を使う. 記事を書きながら採番した UUID v7 の例を以下に載せておく.

  • 018422b2-4843-7a62-935b-b4e65649de3e
  • 018422b2-5013-7f02-830f-2563ce4533df
  • 018422b2-57e8-79b2-8b59-d6926217a9dc

UUID v7 のフィールド構成も 128 bit 長で以下の通り.より詳細な表現はドラフト仕様を参照で!なお,ID の中に「バージョン情報」も含まれるため,3 ブロック目の1文字目は必ず 7 になる.

  • unix_ts_ms: タイムスタンプ情報 48 bit
  • ver: バージョン情報 4 bit
  • rand_a: 乱数 12 bit
  • var: variant 2 bit
  • rand_b: 乱数 62 bit
 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                           unix_ts_ms                          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|          unix_ts_ms           |  ver  |       rand_a          |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|var|                        rand_b                             |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                            rand_b                             |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

UUID v7 を Python で試す

uuid6 ライブラリを使うと簡単に UUID v7 で採番できる.以下では2秒間隔で「3種類」の id を採番して,期待通りに比較できている.比較できるということはソートもできる!

from uuid6 import uuid7
import time

# 018422b2-4843-7a62-935b-b4e65649de3e
id1 = uuid7()
print(id1)

time.sleep(2)

# 018422b2-5013-7f02-830f-2563ce4533df
id2 = uuid7()
print(id2)

time.sleep(2)

# 018422b2-57e8-79b2-8b59-d6926217a9dc
id3 = uuid7()
print(id3)

print(id1 < id2)  # True
print(id2 < id3)  # True
print(id3 < id1)  # False

UUID Version 8 (UUID v8)

ドラフト仕様を読むと,UUID v8 は限定的な用途のために策定されていて,正確には「実験的」もしくは「ベンダー固有」と書いてある.UUID v8 のフィールド構成も 128 bit 長だけど,UUID v7 と同じく vervar は必須で,他は自由に設定できる.1点注意点としては,ドラフト仕様に「ランダムで埋めちゃダメ」と書いてあるので,あくまで独自仕様のタイムスタンプ情報を含めることが前提になりそう.例えば「何かしらの理由でタイムスタンプを知られたくない」というときに使えそう?

To be clear: UUIDv8 is not a replacement for UUIDv4 where all 122 extra bits are filled with random data.

  • custom_a: タイムスタンプ情報 48 bit
  • ver: バージョン情報 4 bit
  • custom_b: 乱数 12 bit
  • var: variant 2 bit
  • custom_c: 乱数 62 bit
 0                   1                   2                   3
 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                           custom_a                            |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|          custom_a             |  ver  |       custom_b        |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|var|                       custom_c                            |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|                           custom_c                            |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

Playwright for Python: ブラウザ操作を自動化しよう!

ブラウザ操作を自動化して,E2E (End to End) テストやスクレイピングをするときに使えるライブラリ Playwright を試す.Playwright の特徴としてまず「クロスブラウザ」があり,Chromium / Firefox / WebKit をサポートしている.また「複数言語」という特徴もあり,Node.js / Python / Java / .NET をサポートしている.今回は Playwright for Python を前提にする.

playwright.dev

Playwright の前に

過去の経験を思い出すと,Capybara + PhantomJS で E2E テストを実装していた(2015-2016年頃でなつかしい!).また最近だと Puppeteer や Amazon CloudWatch Synthetics を使う機会も増えている.しかし,個人的に Node.js にあまり慣れてなく,他の選択肢を探していたところ,pyppeteer(Python に移植された Puppeteer)経由で Playwright for Python というライブラリに出会った.

Playwright に入門する

まず,雰囲気を掴むために Getting started のコードを参考にしながらザッと実装してみた.

playwright.dev

今回は Chromium を使う.page.goto() で Playwright のサイトにアクセスして,page.title() で title tag の値(タイトル)を取得している.そして page.locator() を使ってセレクタを記述できるので,page.locator('.hero__title') で画面上部の「ヒーロー」を特定したり,page.locator('.lightToggleIcon_pyhR') で画面右上の light / dark モードを切り替えるボタンを特定したりしている..text_content() を使えばテキストを取得できるし,.click() を使えば要素をクリックできる.

from playwright.sync_api import sync_playwright
from rich import print

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto('https://playwright.dev/')

    # Fast and reliable end-to-end testing for modern web apps | Playwright
    print(page.title())
    # Playwright enables reliable end-to-end testing for modern web apps.
    print(page.locator('.hero__title').text_content())

    page.screenshot(path='images/playwright-light.png')
    page.locator('.lightToggleIcon_pyhR').click()
    page.screenshot(path='images/playwright-dark.png')

    browser.close()

そして,page.screenshot() で取得したスクリーンショットを以下に貼っておく.Playwright には他にも多くの機能があり,ドキュメントも充実している.便利!

pytest 連携

Playwright for Python は pytest と連携して,簡単に E2E テストを実装できる.以下にはドキュメントを参考に抜粋したサンプルコードを載せているけど,Playwright のサイトのタイトルに Playwright が含まれるかどうかをテストしている.

import re

from playwright.sync_api import Page, expect

def test_playwright(page: Page):
    page.goto('https://playwright.dev/')
    expect(page).to_have_title(re.compile('Playwright'))

pytest を実行するとちゃんと 1 passed と表示される!

$ pytest
test_playwright.py
.                                                                                           [100%]

================================================= 1 passed in 2.33s ==================================================

playwright.dev

Codegen(コード生成)

Playwright の便利な機能の一つに「Codegen(コード生成)」がある.playwright codegen コマンドを実行すると Playwright Inspector というデバッグにも使える機能が起動して,ブラウザを操作しながら Playwright for Python のコードを自動生成できる.ブラウザを操作するコードも pytest コードもサポートしている.コード自体は完璧ではなく,手直しが必要になることもある.

$ playwright codegen https://playwright.dev -o codegen.py

playwright.dev

Interactive mode (REPL)

紹介した Codegen も便利だけど,もっと細かく試行錯誤するときには Python の REPL も使える.特に .locator を使ってセレクタを記述するときに試行錯誤する必要があって,何度も REPL にはお世話になった.

>>> from playwright.sync_api import sync_playwright
>>> playwright = sync_playwright().start()
>>> browser = playwright.chromium.launch(headless=False)
>>> page = browser.new_page()
>>> page.goto('https://kakakakakku.hatenablog.com/')
<Response url='https://kakakakakku.hatenablog.com/' request=<Request url='https://kakakakakku.hatenablog.com/' method='GET'>>
>>> browser.close()
>>> playwright.stop()

kakakakakku blog で複数要素を取得する

他にもコードを試すために,kakakakakku blog の「人気記事」を取得するコードを書いてみた.「人気記事」は ul tag の中に 10 個の li tag が並んでいるため,複数要素を取得するコードを書く必要がある.Locators のドキュメントには「セレクタを複雑に書くのはアンチパターン」と書いてあって,微妙かもしれないけど,for 文と .nth() を組み合わせて記事タイトルを取得してみた.

from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch()
    page = browser.new_page()
    page.goto('https://kakakakakku.hatenablog.com/')

    entries = page.locator('ul.entries-access-ranking li')

    for i in range(entries.count()):
        print(entries.nth(i).locator('div > .urllist-title').text_content())

    browser.close()

playwright.dev

まとめ

今回はここまで!Playwright for Python に入門した.実は個人的に実装しようと思っているアイデアがあって,さっそく Playwright を使って実装に着手している.Playwright では,要素が存在しないときに自動的に待ってくれる「Auto-waiting 機能」があるのも便利だった.まだまだ入門したばかりだし,ドキュメントを参考にして実装したコードも改善できそう.Playwright には多くの機能があるので,引き続き試していくぞー!

playwright.dev

ソートできるユニークな ID を ULID で採番する

ユニークな ID を採番するときに UUID (Universally Unique Identifier) v4 を使う場面は多いと思う.しかし要件によっては UUID だと「順序性がなくソートしにくい」という側面もあったりする.今回はユニーク性を維持しつつミリ秒精度でソートできる ULID (Universally Unique Lexicographically Sortable Identifier) を試す.仕様などの詳細は以下の GitHub に載っている.

github.com

ulid-py

ULID ライブラリは多くの言語で実装されている.今回は Python で使えるライブラリ「ulid-py」を試す.

$ pip install ulid-py

github.com

基本機能

Python インタプリタを使って ulid-py の基本機能を試す.ulid-py では ulid.new() を使って ID を採番できる.そして .str を使うと文字列として取得できる.

>>> import ulid

>>> id = ulid.new()
>>> id
<ULID('01GF2EB9Z7G7K2D6F1ZWJA9913')>
>>> id.str
'01GF2EB9Z7G7K2D6F1ZWJA9913'

ULID の ID フォーマットは以下のように Timestamp(タイムスタンプ)Randomness(ランダム) で構成される.

 01GF2EB9Z7      G7K2D6F1ZWJA9913

|----------|    |----------------|
 Timestamp          Randomness
   48bits             80bits

timestamp().str を使うと Timestamp を文字列として取得できる.さらに .timestamp().datetime を使うと Python の datetime 型で日付を取得できる.

>>> id.timestamp()
<Timestamp('01GF2EB9Z7')>
>>> id.timestamp().str
'01GF2EB9Z7'
>>> id.timestamp().int
1665455728615
>>> id.timestamp().datetime
datetime.datetime(2022, 10, 11, 2, 35, 28, 615000, tzinfo=datetime.timezone.utc)

さらに .randomness().str を使うと Randomness を文字列として取得できる.

>>> id.randomness()
<Randomness('G7K2D6F1ZWJA9913')>
>>> id.randomness().str
'G7K2D6F1ZWJA9913'

id を比較する

以下のように3種類の ID を数秒待ちつつ採番する.期待通りに比較できる.比較できるということはソートもできる.

>>> id1 = ulid.new()
>>> id2 = ulid.new()
>>> id3 = ulid.new()

>>> id1 < id2
True
>>> id1 < id3
True
>>> id3 < id1
False

同時に採番する

ulid-py では「ミリ秒精度の」タイムスタンプが同じでも Randomness(ランダム) で ID が重複しないように考慮されている.以下は ulid.from_timestamp() を使って「特定のタイムスタンプ」から ID を3種類採番している.Timestamp(タイムスタンプ)01FR8G2EC0 で同じになりつつも Randomness(ランダム) で異なる id を採番できている.

>>> import datetime

>>> ulid.from_timestamp(datetime.datetime(2022, 1, 1))
<ULID('01FR8G2EC0643MA59P9WTKXSAY')>
>>> ulid.from_timestamp(datetime.datetime(2022, 1, 1))
<ULID('01FR8G2EC0DZR7HD80ARGVG2CM')>
>>> ulid.from_timestamp(datetime.datetime(2022, 1, 1))
<ULID('01FR8G2EC0FT0EZXYQDE56R9TV')>

JST でタイムスタンプを取得する

ULID のタイムスタンプを JST に変換する場合は .astimezone()dateutil ライブラリを使う.

>>> id.timestamp().datetime.astimezone(datetime.timezone(datetime.timedelta(hours=+9)))
datetime.datetime(2022, 10, 11, 11, 35, 28, 615000, tzinfo=datetime.timezone(datetime.timedelta(seconds=32400)))

>>> from dateutil.tz import gettz

>>> id.timestamp().datetime.astimezone(gettz('Asia/Tokyo'))
datetime.datetime(2022, 10, 11, 11, 35, 28, 615000, tzinfo=tzfile('/usr/share/zoneinfo/Asia/Tokyo'))