環境
Python 3.11.3
で以下のパッケージをインストールします。
requirements.txt
1
2
3
4
pandas == 2 .0.1
pandas-stubs== 2 .0.1.230501
flake8 == 6 .0.0
mypy == 1 .3.0
重いライブラリとしてpandasを使い、
型付けチェックとしてflake8とmypyでチェックします。
globalにimportする場合
普通にファイルの先頭でimportする場合。
test0.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
from pathlib import Path
import pandas as pd
def get_df ( file : str ) -> pd . DataFrame | None :
if Path ( file ) . exists ():
return pd . read_csv ( file )
return None
def print_df ( file : str ) -> None :
df : pd . DataFrame | None = get_df ( file )
if df is not None :
print ( df )
こんなファイルを考えます。
このファイルでは全ての関数でpd
を使ってるのでimportするとすればPandasを読み込まないと何も意味ないレベルのものですが、
実際には他にも色々とあってこの部分が使われないこともあるとして。
このファイルを型チェックをしてみたり、実際にimportしてみたりすると
1
2
3
4
5
$ flake8 test0.py
$ mypy test0.py
Success: no issues found in 1 source file
$ python -c 'import test0'
$
といった感じでなんのエラーも起きずにうまくいきます。
importを関数内で行う
先頭で行うとこのファイルを読み込む際に必ず読み込むことになるので
関数内で必要なところだけで呼んでみます。
test1.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pathlib import Path
def get_df ( file : str ) -> pd . DataFrame | None :
if Path ( file ) . exists ():
import pandas as pd
return pd . read_csv ( file )
return None
def print_df ( file : str ) -> None :
import pandas as pd
df : pd . DataFrame | None = get_df ( file )
if df is not None :
print ( df )
これで関数内ではうまくいきますが、
返り値の値として型をつけてる部分が問題になります。
1
2
3
4
5
6
7
8
9
10
11
12
13
$ flake8 test1.py
test1.py:4:26: F821 undefined name 'pd'
$ mypy test1.py
test1.py:4: error: Name "pd" is not defined [name-defined]
Found 1 error in 1 file (checked 1 source file)
$ python -c 'import test1'
Traceback (most recent call last):
File "<string>", line 1, in <module>
File ".../test1.py", line 4, in <module>
def get_df(file: str) -> pd.DataFrame | None:
^^
NameError: name 'pd' is not defined. Did you mean: 'id'?
$
型チェックも失敗しますが実際に実行する際にもエラーになるのでこれは絶対に使えません。
もちろんここで型チェックに妥協してそもそも返り値の型を書かないようにしたり
typing.Any
を使えば型チェッカーを通せますしエラーも出ません。
ただ、ちゃんと型をつけようとしてmypyに--disallow-untyped-defs
をつけている場合などは
error: Function is missing a return type annotation [no-untyped-def]
のエラーが出ます。
今回はこれも回避出来るようにちゃんと型を付けて見たいと思います。
TYPE_CHECKINGを使う
関数の返り値の部分は関数外になってしまうので、ここのところでクラスを使いたい場合には
どうしてもglobalな部分でimportするしかありません。
この場合、typing.TYPE_CHEKING (Python 3.5.2で導入)
という値を使うことが出来ます。
typing.TYPE_CHECKING
は型をチェックする際にだけTrue
になる値で、通常の実行時にはFalse
になります。
実際実行時には
1
2
3
4
5
6
7
from typing import TYPE_CHECKING
print ( f 'TYPE_CHECKING: { TYPE_CHECKING } ' )
if TYPE_CHECKING :
print ( 'type checking is true' )
みたいなものを実行すれば
とだけ出て、とりあえず実行時にはFalse
になっていることはわかります。
これを使って
test2.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING :
import pandas as pd
def get_df ( file : str ) -> pd . DataFrame | None :
if Path ( file ) . exists ():
import pandas as pd
return pd . read_csv ( file )
return None
def print_df ( file : str ) -> None :
df : pd . DataFrame | None = get_df ( file )
if df is not None :
print ( df )
このようにすると、flake8やmypyでチェックする時には最初の
import
は呼ばれますが、
実際に実行する際には呼ばれません。
これで返り値のチェックはクリア出来ます。
ただ、これをやってみると
1
2
3
4
5
6
7
8
9
10
11
$ flake8 test2.py
$ mypy test2.py
Success: no issues found in 1 source file
$ python -c 'import test2'
Traceback (most recent call last):
File "<string>", line 1, in <module>
File ".../test2.py", line 7, in <module>
def get_df(file: str) -> pd.DataFrame | None:
^^
NameError: name 'pd' is not defined. Did you mean: 'id'?
$
のように、型チェッカーのチェックは通りますが、実際に実行する際にも
型のチェックはしませんがその部分は見えるためpd
というものがない、ということだけはわかり
エラーになってしまいます。
また、print_df
の関数内で使っている値への型付けの部分でもimport
を外していますが、
ここに関しては型チェック用のTYPE_CHECKINGの中でimportされていれば
文句は言われないようになります。
(これは後で実証します。)
文字列にする
これを対処するために、型の部分を文字列にする方法があります。
test3.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING :
import pandas as pd
def get_df ( file : str ) -> 'pd.DataFrame' | None :
if Path ( file ) . exists ():
import pandas as pd
return pd . read_csv ( file )
return None
def print_df ( file : str ) -> None :
df : pd . DataFrame | None = get_df ( file )
if df is not None :
print ( df )
こうすることにより、flake8やmypyではpd.DataFrame
の部分をチェックしますが
実際に実行する際にはアノテーションとしてなにか文字列があるな、
と思うだけで特にエラーを出しません。
ただ、また別の問題が出ます。
1
2
3
4
5
6
7
8
9
10
11
$ flake8 test3.py
$ mypy test3.py
Success: no issues found in 1 source file
$ python -c 'import test3'
Traceback (most recent call last):
File "<string>", line 1, in <module>
File ".../test3.py", line 7, in <module>
def get_df(file: str) -> 'pd.DataFrame' | None:
~~~~~~~~~~~~~~~^~~~~~
TypeError: unsupported operand type(s) for |: 'str' and 'NoneType'
$
問題は|
を使って複数の型を指定する方法がこのような型を文字列で書いた場合
うまくいかない、という点。
Optionalを使う
そこでこの|
を使えるようになるPython 3.10より前まで使われていたtyping.Union
を使うかtyping.Optional
を使います。
下ではNone
との組み合わせですが|
と同じように書いて見るためにUnion
で書いてみます。
test4.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pathlib import Path
from typing import TYPE_CHECKING , Union
if TYPE_CHECKING :
import pandas as pd
def get_df ( file : str ) -> Union [ 'pd.DataFrame' , None ]:
if Path ( file ) . exists ():
import pandas as pd
return pd . read_csv ( file )
return None
def print_df ( file : str ) -> None :
df : pd . DataFrame | None = get_df ( file )
if df is not None :
print ( df )
1
2
3
4
5
6
$ flake8 test4.py
$ flake8 test4.py
$ mypy test4.py
Success: no issues found in 1 source file
$ python -c 'import test4'
$
これで型チェックも実際に実行する際にもうまくいきました。
文字列でちゃんとチェックできているのか?
文字列にすると実行時には関係なくなるという状態ですが、
型チェックする際にも単に無視されているとしたら意味がありません。
これを試すために関数の受け取り側で違う型を指定してみると、
test5
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from pathlib import Path
from typing import TYPE_CHECKING , Union
if TYPE_CHECKING :
import pandas as pd
def get_df ( file : str ) -> Union [ 'str' , None ]:
if Path ( file ) . exists ():
import pandas as pd
return pd . read_csv ( file )
return None
def print_df ( file : str ) -> None :
df : str | None = get_df ( file )
if df is not None :
print ( df )
1
2
3
4
5
6
$ flake8 test5.py
$ mypy test5.py
test5.py:15: error: Incompatible types in assignment (expression has type "Optional[DataFrame]", variable has type "Optional[str]") [assignment]
Found 1 error in 1 file (checked 1 source file)
$ python -c 'import test5'
$
といった感じにflake8は検出出来てませんが
mypyではきちんとOptional[str]
であるべき所にOptional[DataFrame]
が来ている、ということで
間違いを検出していますし、きちんと関数の返り値の部分がpd.DataFrame
であることも認識しています。
文字列にせず実行時には見ないようにする
型チェックの部分を実行時に見てもそこで型のチェックをしているわけでは無いので意味がありません。
現状ではこれをquoteして文字列にすることで回避していますが、これをquoteなしでそのまま
実行時には見ず、型チェックのときだけ見るようにするようにする方向がPEP 563 で定義されています。
これは現在はfrom __future__ import annotations
を使うと有効になります。
また、これによりUnion
を使わずに|
で書くことも可能になります。
test6
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING , Union
if TYPE_CHECKING :
import pandas as pd
def get_df ( file : str ) -> pd . DataFrame | None :
if Path ( file ) . exists ():
import pandas as pd
return pd . read_csv ( file )
return None
def print_df ( file : str ) -> None :
df : pd . DataFrame | None = get_df ( file )
if df is not None :
print ( df )
1
2
3
4
5
$ flake8 test6.py
$ mypy test6.py
Success: no issues found in 1 source file
$ python -c 'import test6'
$
この機能はいずれ
from __future__ import annotations
なしでも有効になるはずです。
実行速度
実際に使えるtest0とtest4のバージョンをimportする時間について見てます。
手元の環境でpython -X importtime
を使って見てみると
先頭で必ずimportする場合は
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ time python -X importtime -c 'import test0' 2>&1 |tail -n10
import time: 146 | 383 | pandas.io.sas
import time: 101 | 101 | pandas.io.spss
import time: 705 | 705 | pandas.io.sql
import time: 2518 | 2518 | pandas.io.stata
import time: 654 | 654 | pandas.io.xml
import time: 493 | 24304 | pandas.io.api
import time: 103 | 103 | pandas.util._tester
import time: 80 | 80 | pandas._version
import time: 510 | 230275 | pandas
import time: 195 | 233178 | test0
real 0m0.292s
user 0m0.359s
sys 0m0.457s
importtimeの各列は
1
import time: self [us] | cumulative | imported package
で、self [us]
はそのパッケージをimportするのにかかった時間、cumulative
は
パッケージ内でさらに他のパッケージをimportしている場合、それらも含めたトータルの時間です。
pandas
のロードでトータル230,275 us(0.2秒)かかっていることがわかり、
それ以外は0.003秒ほどで100分の1ほどです。
test4の方をやってみると
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ time python -X importtime -c 'import test4' 2>&1 |tail -n10
import time: 210 | 210 | warnings
import time: 53 | 53 | errno
import time: 96 | 96 | urllib
import time: 713 | 809 | urllib.parse
import time: 667 | 2397 | pathlib
import time: 122 | 122 | collections.abc
import time: 410 | 410 | contextlib
import time: 113 | 113 | _typing
import time: 1805 | 2449 | typing
import time: 1529 | 6373 | test4
real 0m0.025s
user 0m0.026s
sys 0m0.000s
こんな感じでpandas
は呼ばれず、トータルで0.006秒ほどに抑えられています。
50分の1ほど。
実際の実行時間もuser
を見ると0m0.359sから0m0.026sと10分の1以下になっています。
ここにはターミナル出力等も含まれるので純粋なpythonのimportの読み込み時間としては
50分の1ほどです。
test6のバージョンも見てみると
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ time python -X importtime -c 'import test6' 2>&1 |tail -n10
import time: 269 | 269 | warnings
import time: 54 | 54 | errno
import time: 93 | 93 | urllib
import time: 712 | 805 | urllib.parse
import time: 749 | 2509 | pathlib
import time: 151 | 151 | collections.abc
import time: 408 | 408 | contextlib
import time: 123 | 123 | _typing
import time: 1810 | 2491 | typing
import time: 217 | 5331 | test6
real 0m0.025s
user 0m0.026s
sys 0m0.000s
ということでannotations
のimportはほとんど時間がかからないので同じような時間になっています。
関数中での型付けのチェック
途中で返り値ではなく実行文中での型付けの部分に関しては
TYPE_CHECKING
でガードされて実行時にはimportされないクラスなどを
使っても問題ない、という話をしましたがそれをチェックしてみます。
test4のprint_df
の関数を実際に使ってみます。
test7.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
from pathlib import Path
from typing import TYPE_CHECKING , Union
if TYPE_CHECKING :
import pandas as pd
def get_df ( file : str ) -> Union [ 'pd.DataFrame' , None ]:
if Path ( file ) . exists ():
import pandas as pd
return pd . read_csv ( file )
return None
def print_df ( file : str ) -> None :
df : pd . DataFrame | None = get_df ( file )
if df is not None :
print ( df )
if __name__ == '__main__' :
print_df ( 'a.csv' )
a.csv がない場合にはget_df
の返り値もNone
でpandasは一切呼ばれずに実行されますが
エラーなく実行できています。
ある場合でも
1
2
3
4
5
6
7
8
9
10
$ cat << EOF > a.csv
> a,b,c
> 1,2,3
> 4,5,6
> EOF
$ python test4.py
a b c
0 1 2 3
1 4 5 6
$
といった感じで問題なく実行できます。