iT邦幫忙

2024 iThome 鐵人賽

DAY 18
0

本日內容為gt核心思維分享,如果只是想學習如何使用gt的朋友,可以略過。

[Day01]至[Day17]我們一起學習了gt的各種功能,接下來幾天將進入實務製表的環節。

[Day18]將作為一個緩衝帶,帶領大家看看gt的核心思維,期許能讓您對其有更多的認識,在往後的使用上能夠更佳得心應手。

今天的內容分為六大部份(註1):

  1. 了解Python的虛擬繼承。
  2. 了解Python的singledispatch()
  3. 說明databackend
  4. 說明gt如何運用虛擬繼承來選擇依賴Pandas或Polars。
  5. 說明gt如何運用singledispatch()來分離Pandas或Polars的運算邏輯。
  6. 說明gt的Fluent API原理並客製化一個格式化函數。

1. 了解虛擬繼承

Python abc模組中的abc.ABCMetaabc.ABC,有提供一個register()及一個__subclasshook__()方法,可以幫助我們將某個class視為繼承自某個class,在使用issubclass()進行繼承判斷時,格外有用。這邊需留意,此功能只能視為虛擬繼承(virtual subclass),並非真正繼承。

常見的寫法共有以下四種:

  • abc.ABCMeta + register()
  • abc.ABC + register()
  • abc.ABCMeta + __subclasshook__()
  • abc.ABC + __subclasshook__()

abc.ABCMeta + register()

from abc import ABCMeta


class MyABC1(metaclass=ABCMeta): ...


class Dummy1: ...


MyABC1.register(Dummy1)

# Dummy1 is a "virtual subclass" of MyABC1
print(issubclass(Dummy1, MyABC1))  # True
print(isinstance(Dummy1(), MyABC1))  # True

abc.ABCMeta是一個metaclass,使用方法是於定義class時,使用關鍵字metaclass將其傳入。例如定義MyABC1時,使用metaclass=ABCMetaMyABC1metaclass設定為abc.ABCMeta

接著我們定義一個Dummy1,其是直接由class這個Python保留字所定義的,很明顯不是繼承自MyABC1。但是我們可以使用MyABC1.register(Dummy1)來讓Python將Dummy1視為自MyABC1繼承而來。

我們可以使用issubclass()isinstance()來確認此虛擬繼承的確成功設定。

abc.ABC + register()

from abc import ABC


class MyABC2(ABC): ...


class Dummy2: ...


MyABC2.register(Dummy2)

# Dummy2 is a "virtual subclass" of MyABC
print(issubclass(Dummy2, MyABC2))  # True
print(isinstance(Dummy2(), MyABC2))  # True

由於metaclass對於大部份Python開發者是比較陌生的,所以Python特別提供了abc.ABC這個語法糖,讓我們可以使用繼承來設定metaclassabc.ABCMeta。此處MyABC2繼承abc.ABC,即是將其metaclass設定為abc.ABCMeta,其餘細節與上小節相同。

abc.ABCMeta + __subclasshook__()

from abc import ABCMeta


class MyABC3(metaclass=ABCMeta):
    @classmethod
    def __subclasshook__(cls, subclass):
        return True


class Dummy3: ...


# Dummy3 is a "virtual subclass" of MyABC3
print(issubclass(Dummy3, MyABC3))  # True
print(isinstance(Dummy3(), MyABC3))  # True

如果說register()是一種主動讓某個class視為繼承自某個class的方法,那麼
__subclasshook__()就可以說是一種被動的方法。

在定義MyABC3時,我們除了使用metaclass=ABCMetaMyABC3metaclass設定為abc.ABCMeta外,還多加了一個__subclasshook__()classmethod__subclasshook__()可以返回TrueFalseNotImplemented,因此我們可以在其內編寫,在哪些情況下某class會被視為繼承自MyABC3。這裡我們直接將返回值設定為True,也就是任何class都會被視為繼承自MyABC3

我們可以使用issubclass()isinstance()來確認此虛擬繼承的確成功設定。

abc.ABC + __subclasshook__()

from abc import ABC


class MyABC4(ABC):
    @classmethod
    def __subclasshook__(cls, subclass):
        return True


class Dummy4: ...


# Dummy4 is a "virtual subclass" of MyABC4
print(issubclass(Dummy4, MyABC4))  # True
print(isinstance(Dummy4(), MyABC4))  # True

此小節只是改使用繼承abc.ABC的方式來建立MyABC4,其餘細節與上小節相同。

2.了解singledispatch()

singledispatch()是python的functools提供的一個裝飾器,其可以依據傳入參數的型別,來呼叫不同的函數。例如:

from functools import singledispatch


@singledispatch
def my_print(arg):
    print(f"{type(arg)=}, {arg=}")
    raise NotImplementedError(f"{type(arg)=} is not supported yet.")


@my_print.register
def _(arg: int):
    print(f"{type(arg)=}, {arg=}")


@my_print.register
def _(arg: str):
    print(f"{type(arg)=}, {arg=}")


my_print(123)  # type(arg)=<class 'int'>, arg=123
my_print("abc")  # type(arg)=<class 'str'>, arg='abc'
my_print(1.23)  # NotImplementedError: type(arg)=<class 'float'> is not supported yet.

我們定義一個my_print()函數,其接收一個參數arg

接下來我們定義兩個函數(由於其函數名,在此情況下不重要,大家會習慣性地命名為下底線),分別使用@my_print.register來裝飾。兩個函數的arg參數之型別提示不同,分別為intstr,這就是singledispatch()藉以選擇呼叫函數的依據。

當執行my_print(123)時,由於「123」為int型別,所以會呼叫arg型別標註為int的函數。

當執行my_print("abc")時,由於「"abc"」為str型別,所以會呼叫arg型別標註為str的函數。

當執行my_print(1.23)時,由於「1.23」為float型別,是一個沒有被@my_print.register所裝飾的型別,所以會呼叫原始的my_print(),最終raise NotImplementedError

3.databackend 說明

databackend的程式碼不多,所以gt選擇直接將其複製進來,不需另外安裝。其總共有_load_class()_AbstractBackendMetaAbstractBackend三個部份,以下分別說明。

_load_class()

from __future__ import annotations

import sys
import importlib

from typing import Type, List, Tuple

from abc import ABCMeta


def _load_class(mod_name: str, cls_name: str) -> Type[object]:
    mod = importlib.import_module(mod_name)
    return getattr(mod, cls_name)

_load_class()的功用是希望能夠透過mod_namecls_name兩個str型別的參數,來取得某個模組內的某個class

_AbstractBackendMeta

class _AbstractBackendMeta(ABCMeta):
    def register_backend(cls, mod_name: str, cls_name: str):
        cls._backends.append((mod_name, cls_name))
        cls._abc_caches_clear()

_AbstractBackendMeta是一個metaclass,其繼承自abc.ABCMeta,並提供一個類似register()功能的register_backend()

AbstractBackend

class AbstractBackend(metaclass=_AbstractBackendMeta):
    """Represent a class, without needing to import it."""

    _backends: List[Tuple[str, str]]

    @classmethod
    def __init_subclass__(cls):
        if not hasattr(cls, "_backends"):
            cls._backends = []

    @classmethod
    def __subclasshook__(cls, subclass: Type[object]):
        for mod_name, cls_name in cls._backends:
            if mod_name not in sys.modules:
                continue
            else:
                parent_candidate = _load_class(mod_name, cls_name)
                if issubclass(subclass, parent_candidate):
                    return True

        return NotImplemented

AbstractBackend使用_AbstractBackendMeta為其metaclass。其中有一個class變數_backends,可以用來記錄所有繼承AbstractBackendclass之資訊。

AbstractBackend有兩個classmethod

  • __init_subclass__()是當有class繼承AbstractBackend時,如果_backends變數還未建立的話,就指定其為空list型別。
  • __subclasshook__()則是針對_backends中所記錄的資訊,看看是否能夠透過
    _load_class()來取得記錄資訊中所描述的class。如果能找到且能夠通過issubclass()測試的話,則返回True。最終。如果無法於資訊中確認繼承關係的話,則return NotImplemented

4. Pandas及Polars的選擇性依賴

考慮df_pandasdf_polars如下:

import pandas as pd
import polars as pl
from great_tables import GT

df_pandas = pd.DataFrame({"x": ["a", "b"], "y": [1.01, 2.0]})
df_polars = pl.from_pandas(df_pandas)

假如我們想取得兩個DataFrame的欄位名稱,需要使用不同的語法:

df_pandas.columns.tolist()  # pandas
df_polars.columns           # polars

一般直覺的解決方法會是如下面這樣,使用isinstance()來區分邏輯:

def get_column_names(data) -> list[str]:
    if isinstance(data, pd.DataFrame):
        return data.columns.tolist()
    elif isinstance(data, pl.DataFrame):
        return data.columns
    raise TypeError(f"Unsupported type {type(data)}")

但是這麼寫有兩個缺點:

  • 第一個是必須同時安裝兩個套件,否則無法取得各自的DataFrame class來作為isinstance()判斷之用。
  • 第二個是兩種不同DatFrame的運算邏輯會混在同一個函數中。而且假如未來gt支援更多格式的話,還得修改此函數,將更多的邏輯加進來。

聽起來不太妙,對吧?

此時,我們可以利用databackend中的AbstractBackend來定義PdDataFramePlDataFrame。請留意_backends變數,這即是databackend可以自動利用__subclasshook__()來將df_pandasdf_polars視為PdDataFramePlDataFrame的虛擬繼承的關鍵(註2)。

from great_tables._databackend import AbstractBackend


class PdDataFrame(AbstractBackend):
    _backends = [("pandas", "DataFrame")]


class PlDataFrame(AbstractBackend):
    _backends = [("polars", "DataFrame")]

這麼一來,我們就可以在不安裝Polars的情況下,仍能確定df_pandas來自Pandas。例如:

if isinstance(df_pandas, PdDataFrame):
    print("I'm a pandas DataFrame!!!")
I'm a pandas DataFrame!!!

5. Pandas及Polars的運算邏輯

在解決完依賴問題後,我們可以利用PdDataFramePdDataFrame來作為singledispatch()呼叫函數時的參數型別提示。這麼一來,我們就可以將各自的運算邏輯分離至不同的函數。例如:

from functools import singledispatch


@singledispatch
def get_column_names(data) -> list[str]:
    raise TypeError(f"Unsupported type {type(data)}")

@get_column_names.register
def _(data: PdDataFrame):
    return data.columns.tolist()

@get_column_names.register
def _(data: PlDataFrame):
    return data.columns

此時,get_column_names()就可以依照傳入的DataFrame種類來呼叫適合的函數,處理各自的運算邏輯。例如:

get_column_names(df_pandas)  # pandas version
get_column_names(df_polars)  # polars version
['x', 'y']

6. gt的Fluent API

為了說明gt的Fluent API,我們需要觀察其中的great_tables資料夾(註3)。

其中_gt_data.py中的GTDatagt.py中的GT是整個架構的核心。

您可以將GTData想做是表格中各個component的集合。

# _gt_data.py

@dataclass(frozen=True)
class GTData:
    _tbl_data: TblData
    _body: Body
    _boxhead: Boxhead
    _stub: Stub
    _spanners: Spanners
    _heading: Heading
    _stubhead: Stubhead
    _source_notes: SourceNotes
    _footnotes: Footnotes
    _styles: Styles
    _locale: Locale | None
    _formats: Formats
    _substitutions: Formats
    _options: Options
    _has_built: bool = False

    def _replace(self, **kwargs: Any) -> Self:
        new_obj = copy.copy(self)

        missing = {k for k in kwargs if k not in new_obj.__dict__}
        if missing:
            raise ValueError(f"Replacements not in data: {missing}")

        new_obj.__dict__.update(kwargs)

        return new_obj

    @classmethod
    def from_data(
        cls,
        data: TblData,
        rowname_col: str | None = None,
        groupname_col: str | None = None,
        auto_align: bool = True,
        id: str | None = None,
        locale: str | None = None,
    ):
        data = validate_frame(data)
        stub, boxhead = _prep_gt(data, rowname_col, groupname_col, auto_align)

        if id is not None:
            options = Options(
                table_id=OptionsInfo(True, "table", "value", id)
            )
        else:
            options = Options()

        return cls(
            _tbl_data=data,
            _body=Body.from_empty(data),
            _boxhead=boxhead,  # uses get_tbl_data()
            _stub=stub,  # uses get_tbl_data
            _spanners=Spanners([]),
            _heading=Heading(),
            _stubhead=None,
            _source_notes=[],
            _footnotes=[],
            _styles=[],
            _locale=Locale(locale),
            _formats=[],
            _substitutions=[],
            _options=options,
        )

至於GT則繼承GTData而來,並作為提供Fluent API的介面,也就是說所有Fluent API,例如GT.fmt_number()GT.opt_stylize()等等,都可視為GT的instance method(2024/10/27修訂)。

# gt.py

class GT(GTData):
    def __init__(
        self,
        data: Any,
        rowname_col: str | None = None,
        groupname_col: str | None = None,
        auto_align: bool = True,
        id: str | None = None,
        locale: str | None = None,
    ):
        gtdata = GTData.from_data(
            data,
            rowname_col=rowname_col,
            groupname_col=groupname_col,
            auto_align=auto_align,
            id=id,
            locale=locale,
        )
        super().__init__(**gtdata.__dict__)

    ...
    fmt_number = fmt_number
    opt_stylize = opt_stylize
    cols_align = cols_align
    tab_header = tab_header
    tab_stub = tab_stub
    save = save
    ...

添加客製化的Fluent API

在了解了Fluent API後,我們其實可以自己添加想要的功能。例如下面我們展示了如何實做一個fmt_double_plus_n_as_integer()的formatter,其功能是能夠將傳入的數字乘以2再加上使用者指定的數字後,以整數型別呈現於表格。

def fmt_double_plus_n_as_integer(
    self: GT,
    columns: "SelectExpr" = None,
    rows: int | list[int] | None = None,
    plus_n: str | int | float = 1,
):
    def fmt_fn(x: str | int | float | None, plus_n=plus_n):
        print(f"fmt_fn called: current {x=}")
        if x is None:
            return x
        return int(float(x) * 2 + float(plus_n))

    return self.fmt(fns=fmt_fn, columns=columns, rows=rows)


class GT2(GT):
    fmt_double_plus_n_as_integer = fmt_double_plus_n_as_integer

fmt_double_plus_n_as_integer()可以利用GT.fmt()這個公開的Fluent API來處理格式,其要旨是將運算的邏輯寫於fmt_fn()這個函數中並傳遞給GT.fmt()。事實上,大部份的GT.fmt_*()也都是依靠GT.fmt()完成格式化

雖然實務上您可能需要接收更多的參數、做更多型別確認或是缺失值處理,但相信現在您已經更加理解Fluent API的原理了。

最後我們可以定義GT2(繼承GT而來),並將fmt_double_plus_n_as_integer加入其中。

我們使用之前的df_demo來作為測試:
df_demo

首先,我們針對「"int"」欄位進行格式化,plus_n設為「10」:

(GT2(df_demo).fmt_double_plus_n_as_integer(columns="int", plus_n=10))

custom fmt 1

fmt_fn called: current x=1
fmt_fn called: current x=2
fmt_fn called: current x=3

其表格可以正確呈現。另外,我們可以從內部印出的訊息觀察出,其實gt是使用迴圈的方式,依次對欄位內各行進行格式化。

接著針對「"float1"」欄位進行格式化,plus_n設為「"5"」:

(GT2(df_demo).fmt_double_plus_n_as_integer(columns="float1", plus_n="5"))

custom fmt 2

fmt_fn called: current x=4.4
fmt_fn called: current x=5.5
fmt_fn called: current x=6.6

表格也可以正確呈現。這邊需留意,此處的plus_nstr型別的「"5"」,但因為已經於fmt_fn()做了處理,所以可以正常執行。這就是我們前面提過的,為了讓使用者有更好的使用體驗,可能需要做更多的型別轉換或是缺失值處理等動作。

或許您會覺得這樣隱性幫使用者轉換型別不太好?但是根據小弟自己的觀察,在製作表格時,其實大多數人很容易忘記各個欄位的型別,潛意識裡會自動將所有欄位視為str

備註

註1:本日內容很大一部份為參考Michael所寫的databackend及其於gt發表的部落格文章

註2:這裡展示的是使用_backends搭配__subclasshook__()的被動寫法。您也可以主動使用像是AbstractPandasFrame.register_backend("pandas", "DataFrame")來完成虛擬繼承的設定。

註3:great_tables資料夾中所包含的檔案名稱:

├── __init__.py
├── _body.py
├── _boxhead.py
├── _data_color
│   ├── __init__.py
│   ├── base.py
│   ├── constants.py
│   └── palettes.py
├── _databackend.py
├── _export.py
├── _footnotes.py
├── _formats.py
├── _formats_vals.py
├── _gt_data.py
├── _heading.py
├── _helpers.py
├── _locale.py
├── _locations.py
├── _modify_rows.py
├── _options.py
├── _render.py
├── _row_groups.py
├── _scss.py
├── _source_notes.py
├── _spanners.py
├── _stub.py
├── _stubhead.py
├── _styles.py
├── _substitution.py
├── _tab_create_modify.py
├── _tbl_data.py
├── _text.py
├── _types.py
├── _utils.py
├── _utils_nanoplots.py
├── _utils_render_html.py
├── css
│   ├── gt_colors.scss
│   └── gt_styles_default.scss
├── data
│   ├── 01-countrypops.csv
│   ├── ...
│   ├── metro_images
│   │   ├── metro_1.svg
│   │   ├── ...
│   ├── x-airquality.csv
│   ├── ...
├── gt.py
├── loc.py
├── py.typed
├── shiny.py
├── style.py
├── utils_render_common.py
└── vals.py

Code

本日程式碼傳送門


上一篇
[Day17] - 滄海遺珠
下一篇
[Day19] - 範例1:天干地支西元年份對照表
系列文
眾裏尋它:Python表格利器Great Tables30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言