本日內容為gt
核心思維分享,如果只是想學習如何使用gt
的朋友,可以略過。
[Day01]至[Day17]我們一起學習了gt
的各種功能,接下來幾天將進入實務製表的環節。
[Day18]將作為一個緩衝帶,帶領大家看看gt
的核心思維,期許能讓您對其有更多的認識,在往後的使用上能夠更佳得心應手。
今天的內容分為六大部份(註1):
singledispatch()
。databackend
。gt
如何運用虛擬繼承來選擇依賴Pandas或Polars。gt
如何運用singledispatch()
來分離Pandas或Polars的運算邏輯。gt
的Fluent API原理並客製化一個格式化函數。Python abc模組中的abc.ABCMeta或abc.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=ABCMeta
將MyABC1
的metaclass
設定為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
這個語法糖,讓我們可以使用繼承來設定metaclass
為abc.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=ABCMeta
將MyABC3
的metaclass
設定為abc.ABCMeta
外,還多加了一個__subclasshook__()
的classmethod
。__subclasshook__()
可以返回True
、False
及NotImplemented
,因此我們可以在其內編寫,在哪些情況下某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
,其餘細節與上小節相同。
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
參數之型別提示不同,分別為int
及str
,這就是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
。
databackend
說明databackend的程式碼不多,所以gt
選擇直接將其複製進來,不需另外安裝。其總共有_load_class()
、_AbstractBackendMeta
及AbstractBackend
三個部份,以下分別說明。
_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_name
及cls_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
,可以用來記錄所有繼承AbstractBackend
的class
之資訊。
AbstractBackend
有兩個classmethod
:
__init_subclass__()
是當有class
繼承AbstractBackend
時,如果_backends
變數還未建立的話,就指定其為空list
型別。__subclasshook__()
則是針對_backends
中所記錄的資訊,看看是否能夠透過_load_class()
來取得記錄資訊中所描述的class
。如果能找到且能夠通過issubclass()
測試的話,則返回True
。最終。如果無法於資訊中確認繼承關係的話,則return NotImplemented
。考慮df_pandas
及df_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)}")
但是這麼寫有兩個缺點:
class
來作為isinstance()
判斷之用。gt
支援更多格式的話,還得修改此函數,將更多的邏輯加進來。聽起來不太妙,對吧?
此時,我們可以利用databackend
中的AbstractBackend
來定義PdDataFrame
及PlDataFrame
。請留意_backends
變數,這即是databackend
可以自動利用__subclasshook__()
來將df_pandas
或df_polars
視為PdDataFrame
或PlDataFrame
的虛擬繼承的關鍵(註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!!!
在解決完依賴問題後,我們可以利用PdDataFrame
與PdDataFrame
來作為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']
gt
的Fluent API為了說明gt
的Fluent API,我們需要觀察其中的great_tables
資料夾(註3)。
其中_gt_data.py
中的GTData
及gt.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後,我們其實可以自己添加想要的功能。例如下面我們展示了如何實做一個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
來作為測試:
首先,我們針對「"int"」欄位進行格式化,plus_n
設為「10」:
(GT2(df_demo).fmt_double_plus_n_as_integer(columns="int", plus_n=10))
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"))
fmt_fn called: current x=4.4
fmt_fn called: current x=5.5
fmt_fn called: current x=6.6
表格也可以正確呈現。這邊需留意,此處的plus_n
是str
型別的「"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