Slide 1

Slide 1 text

Djangoでのプロジェクトだって 型ヒントを運用出来る! 2021-07-03 @Django Congress 2021 mizzsugar0425 1

Slide 2

Slide 2 text

Agenda ● Pythonで型ヒント使うメリット・デメリット ● Djangoでの型ヒントの使い方 ● Django製プロジェクトの開発現場の型ヒント ○ 導入前に決めたこと ○ 実際に使ってみて困ったこと ○ より快適に型ヒントを運用するために ● おまけコーナー 2

Slide 3

Slide 3 text

お前、誰よ? ● Twitterはこのアイコン (@mizzsugar0425)→ ● Python歴3年 ● Spready株式会社 (we are hiring!!) ● 好き: コーヒー、自転車、ビール ● 静岡住みでフルリモートで就業中 3

Slide 4

Slide 4 text

Spready株式会社について 設立: 2018-05-01 ミッション: やりたいに出会い続ける世界をつくる ビジョン: 人と組織の新しい“つながり”をつくる 4

Slide 5

Slide 5 text

Spready株式会社について 5 コラボレーションSNS 『Spready』 日本で初めてのソーシャルキャピタルの思想を 個人向けプロフィール 『Profiee』 相互理解サポートツール 『Profiee Teams』

Slide 6

Slide 6 text

では、型ヒントの話へ… 6

Slide 7

Slide 7 text

前提 ● 型ヒントの文法については説明しません。 ● Djangoのお作法ついては説明しません。 ● 資料をみやすくするために一部PEP8に沿っていなかったりimportを省いている ソースコードがあります。 ● サンプルコードの技術スタック ○ Python 3.9.5 ○ Django 3.2.5 ○ mypy 0.910 ○ django-stubs 1.8.0 https://github.com/mizzsugar/django-congress-jp-2021 7

Slide 8

Slide 8 text

本発表の対象者 ● Pythonでの型ヒントの基本的な文法を知っている人。 ● Django Tutorialの内容を理解しているレベルの人。 ● Djangoのプロジェクトで型ヒントを使いたい人。 ● チーム開発でDjangoと型ヒントの折り合いをどこまでつけたかの 事例を知りたい人。 8

Slide 9

Slide 9 text

本発表のゴール ● チーム開発で型ヒントを導入するメリットを理解出来ている。 ● Djangoのプロジェクトで型ヒントを書けるようになっている。 ● Djangoのプロジェクトでの型ヒントで妥協しない点 / 妥協する点の 判断材料を得ている。 9

Slide 10

Slide 10 text

Pythonでの型ヒントとは? ● typingモジュールがPython 3.5で追加されました。 ● つけるかつけないかは任意。 10 def add(a: int, b: int) -> int: """aとbを足し合わせた数字を返します。 """ return a + b

Slide 11

Slide 11 text

型ヒントのメリット・デメリット メリット ● 型違反による不具合を防げます。 ● 静的解析に裏付けられているとコードリーディング/リファクタリングが より捗ります。 デメリット ● 間違った型ヒントでもプログラムは動くので返って混乱の原因になるかも。 ● 型ヒントに不慣れなメンバーのみで構成されていると開発速度が遅くなるかも。 11

Slide 12

Slide 12 text

Djangoでの使い方 1 pip install mypy django-stubs 12 mypy Pythonでの型ヒントが正しいかどうか解析するツール django-stubs Djangoに型を提供している、mypyのプラグイン

Slide 13

Slide 13 text

Djangoでの使い方 2 mysite ├── blog │ ├── admin.py │ ├── apps.py │ ├── migrations │ ├── models.py │ └── views.py ├── manage.py ├── mypy.ini └── mysite ├── __init__.py ├── settings.py ├── urls.py └── wsgi.py 13

Slide 14

Slide 14 text

Djangoでの使い方 3 mypy.iniに静的解析する内容を記載します。 mysite/mypy.ini 14 [mypy] plugins = mypy_django_plugin.main [mypy.plugins.django-stubs] django_settings_module = "mysite.settings" 複数設定モジュールがある場合は トップレベルのモジュールを指定

Slide 15

Slide 15 text

Djangoでの使い方 4 blog/models.py 15 class Post(models.Model): author = models.ForeignKey(settings.AUTH_USER_MODEL,    on_delete=models.CASCADE) title = models.CharField(max_length=200) text = models.TextField() created_date = models.DateTimeField(default=timezone.now) published_date = models.DateTimeField(blank=True, null=True) def __str__(self) -> int: return self.title

Slide 16

Slide 16 text

Djangoでの使い方 5 >> mypy blog/models.py blog/models.py:9: error: Return type "int" of "__str__" incompatible with return type "str" in supertype "object" blog/models.py:10: error: Incompatible return value type (got "str", expected "int") 16

Slide 17

Slide 17 text

Djangoでの使い方 6 17 def f(post: Post) -> int: return post.title def f2(post: Post) -> str: return post.dummy_attribute blog/models.py:118: error: Incompatible return value type (got "str", expected "int") blog/models.py:121: error: "Post" has no attribute "dummy_attribue"

Slide 18

Slide 18 text

Djangoでの使い方 7 blog/models.py 18 class Post(models.Model): author = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) title = models.CharField(max_length=200) text = models.TextField() published_date = models.DateTimeField(blank=True, null=True) @classmethod def published_list(cls) -> models.QuerySet[Post]: return cls.objects.filter(published_date__lte=timezone.now())

Slide 19

Slide 19 text

Djangoでの使い方 8 blog/models.py 19 from typing import Optional def get_by_id(post_id: int) -> Post: return Post.objects.get(pk=post_id) def get_by_id_use_filter(post_id: int) -> Optional[Post]: return Post.objects.filter(id=post_id).first()

Slide 20

Slide 20 text

休憩 20

Slide 21

Slide 21 text

喝を入れるなど ● 大丈夫? 生きてる? ● 水飲もうね ● 明日はJIMOTOフラペ! 蕎麦! そしてお土産に日本酒とワインを! ● 蕎麦といえば静岡の戸隠そばも美味しいから静岡にもぜひ来てね ● ガッツで後半乗り切ろうね 21

Slide 22

Slide 22 text

Spready株式会社での型ヒント運用 22

Slide 23

Slide 23 text

今回例として紹介するサービス 23 https://spready.jp

Slide 24

Slide 24 text

Spreadyというサービスについて ● 2019年5月ローンチ。 ● 2020年1月に大規模なリニューアルをしました(ソースコードに一部名残あり)。 ● 弊社のサービスの中でもっとも古いサービスだけれども まだまだ新規開発が必要な段階で開発にスピード感が求められています。 24

Slide 25

Slide 25 text

型ヒント運用開始前のSpready ● 型ヒントや自動テストの文化がありませんでした。 ● 仕様の知識が属人化されていました。 ● ビジネスロジックがviewとmodelに変則的に散らばっており、処理を追うのが困難 でした。 ● 型違反による不具合が起こりやすい箇所がちらほら。 「get_time」という関数の返り値が datetime.dateような ● 処理を理解するために隅から隅までソースコードを読まないといけません。 ● 自動テストがないので変更のための動作確認に時間がかかります。 25

Slide 26

Slide 26 text

ソースコードを改善することに決めた理由 ● 現状のまま実装を進めてもスピードは上がらないこと。 ● まだまだ変更や新規開発が必要なフェーズでスピードが必要。 ● ソースコードの可読性を上げないとスピードが上がらないと判断。 26

Slide 27

Slide 27 text

ディレクトリ構成 27 発表用のために一部省略しています。 app ├── spready │ ├── admin.py │ ├── apps.py │ ├── migrations │ ├── models │ ├── utils (Django関係ないライブラリ群) │ └── views ├── manage.py └── settings ├── settings.py ├── urls.py └── wsgi.py

Slide 28

Slide 28 text

ソースコード改善の方針 ● usecasesモジュールを作成し、ビジネスロジックを集結させることで 処理の内容追いやすくします。 ● usecasesモジュールを機能毎のサブモジュールに分割します。 28

Slide 29

Slide 29 text

新・ディレクトリ構成 29 発表用のために一部省略しています。 app ├── spready │ ├── admin.py │ ├── apps.py │ ├── migrations │ ├── models (クエリとモデル定義のみ) │ ├── utils (Django関係ないライブラリ群) │ ├── usecases (ビジネスロジックはここに集結) │ └── views (HTTPの出入り口) ├── manage.py ├── mypy.ini ├── tests └── settings ├── settings.py ├── urls.py └── wsgi.py

Slide 30

Slide 30 text

新・ディレクトリ構成 30 発表用のために一部省略しています。 usecases ├── spreader │ ├── project.py (案件探しやタグ付け) │ ├── casting.py (携わる案件マッチング) └── company ├── contract.py (契約周り) ├── project.py (案件作成・編集) └── casting.py (案件の協力者マッチング)

Slide 31

Slide 31 text

新・ディレクトリ構成 31 views usecases models utils

Slide 32

Slide 32 text

型ヒントを導入することに決めた理由 ● 型ヒントがあるとどんな関数・クラスなのか理解しやすく、処理を追いやすい ● 型ヒントによって裏付けられているとリファクタリングしやすい ● ビジネスロジックには型ヒントをつけようという結論に 32

Slide 33

Slide 33 text

型ヒントに期待すること ● ビジネスロジックを型安全にすることで、頻繁に起こる仕様変更を安心して迎えられ ること。 ● 型安全にすることで安心してリファクタリングを行えるようにすること。 33

Slide 34

Slide 34 text

導入前に決めたこと ● どのモジュールから型ヒントをつけ始めるか ● 型チェックのためにgitのpre-commitフックを使うか ● mypyの --strict オプションを使うか ● いつからCIで型チェックをするか 34

Slide 35

Slide 35 text

どのモジュールから型ヒントをつけ始める? ● 色んなところで使っているmodels? ● Djangoのお作法に左右されないutils? →型ヒントの導入のしやすさから、utilsから開始。  次にviewsもusecasesも依存するmodels。そしてusecases。最後にviews。 35

Slide 36

Slide 36 text

gitのpre-commitフックを使う? 1 gitのpre-commitフックとは… ● git commit をフックにして実際にcommitする前に 指定されたスクリプトを実行する機能。 ● git commit をフックで型チェックをして、エラーがあったら実際にcommitされない仕 組みを作れます。 https://bit.ly/3AvTGXJ 36

Slide 37

Slide 37 text

gitのpre-commitフックを使う? 2 メリット ● CIにmypyを組み込んでいなくてもコミットしたら 自動的にチェックしてくれます。 デメリット ● モジュールごとのチェックなので、コミット内容と関係ない行のエラーも残ります。 ● エラーがあっても強制的にコミット出来る機能があります。 しかし、強制コミットする運用なのは望ましくありません。 →エラー残した状態で強制コミットは避けたいが 関係ないところも直してコミットするのはレビューワーが大変。 ということで、pre-commitで型チェックしないことに。 37

Slide 38

Slide 38 text

--strictオプションを使う? 1 ● mypyコマンドに --strict をつけることで、より厳しい静的解析が行われます。 ● デフォルトで指摘されるのは以下 ○ Optionalと定義していない引数に Noneを渡してはいけない (--strict-optional) ○ 戻り値の型ヒントがNoneとAny以外の時にreturnがないといけない(--warn-no-return) ○ グローバル変数に型をつけて定義しないといけない (--disallow-untyped-globals) ○ 一度定義した変数を別の型のオブジェクトとして再定義してはいけない (--disallow-redefinition) https://mypy.readthedocs.io/en/stable/command_line.html#cmdoption-mypy-strict 38 ドキュメントで「デフォルトで指摘されるのはこれ」と明示されているわけではないのでまだあるかも …。

Slide 39

Slide 39 text

--strictオプションを使う? 2 ● --strictオプションとDjangoORMの親和性󰢄(だと知識が不足していた当時は思っ ていました)(今回の発表で調べているうちに解決したから、おまけコーナーに書きま した!) -> 何回も # type: ignore またはcastを書かないといけなくなります。 ● まずは自分たちが定義した関数・メソッドに正しい型を明示することが大事。 -> デフォルトの設定で足りそう! チームの方針としては --strict オプションなしで! ※個々人で頑張ってみるのはOK󰢐 --disallow-untyped-defs など標準でチェックされない項目だが 指摘されたい項目はmypy.iniに追加。 39

Slide 40

Slide 40 text

CIで型チェクするのはいつから? ● mypyにチェックしてもらえる最小単位はモジュール。 ● utilsの1モジュールだけから? -> utilsにあるのは6モジュールほど →utilsのすべてのモジュールを一気に型ヒントを与えてからスタート。 ● 徐々にmodels, usecasesに型ヒントを与え、CIの対象に。 40

Slide 41

Slide 41 text

実際に導入して困ったこと・対策 ● AbstractBaseUserモデルを継承したUserモデル ● RelatedObjectDoesNotExist ● QuerySetのvaluesやvalues_list ※Spready以外の弊社サービスでの開発中に起こったことも含んでいます。 ※これから説明に使うサンプルコードは、Spready株式会社で実際に書かれているソー スコードではありません。発表用に作成した、架空のブログサービスです。 41

Slide 42

Slide 42 text

AbstractBaseUserとは ● DjangoのUserモデルをカスタマイズするためのクラス。 ● AbstractBaseUserモデルを継承したクラスは Userモデルがもつ認証機能などをもっています。 https://docs.djangoproject.com/ja/3.2/topics/auth/customizing/ 42

Slide 43

Slide 43 text

AbstractBaseUserクラスを継承したモデルのpassword class User(AbstractBaseUser): email = models.EmailField() password = models.TextField(null=True, blank=True) facebook_user_id = models.CharField(max_length=255, null=True, blank=True) 43 blog/models.py

Slide 44

Slide 44 text

継承元のAbstractBaseUserの型定義 vs overrideした定義 >> mypy blog/models.py blog/models.py:47: error: Incompatible types in assignment (expression has type "TextField[Union[str, Combinable, None], Optional[str]]", base class "AbstractBaseUser" defined the type as "CharField[Union[str, int, Combinable], str]") 44 もともと、passwordはNOT NULLのCharFieldで、型ヒントはstr型です。 カスタマイズしたpasswordはNULLABLEはTextFieldで、型ヒントはOptional[str]型で す。

Slide 45

Slide 45 text

passwordの型違反は妥協! class User(AbstractBaseUser): email = models.EmailField() password = models.TextField(null=True, blank=True) # type: ignore facebook_user_id = models.CharField(max_length=255, null=True, blank=True) 45 blog/models.py

Slide 46

Slide 46 text

Userモデルのpasswordは直接使われない想定 user.set_password(raw_password: Optional[str]) -> 引数はOptional[str] -> OAuth認証での新規登録の場合はset_passwordを呼び出さないので Noneが渡されることはない。 user.check_password(raw_password: str) -> 引数はstr -> そもそもNone同士を比較しようとすると型違反。 user.passwordという風には使われない想定。 https://github.com/typeddjango/django-stubs/blob/5c3898d3b0ce22942b2141f36f1e084e226d0f30/django- stubs/contrib/auth/base_user.pyi 46

Slide 47

Slide 47 text

ユーザー定義メソッドを追加 class User(AbstractBaseUser): class Status(models.TextChoices): ACTIVE = ('user_status_active', 'Active') DEACTIVATED = ('user_status_deactivated', 'Deactivated') objects = UserManager() status = models.CharField( max_length=255, choices=Status.choices, ) email = models.EmailField() ... def is_active(self) -> bool: return self.status == User.Status.ACTIVE 47

Slide 48

Slide 48 text

blog/models.py:76: error: Signature of "is_active" incompatible with supertype "AbstractBaseUser" 48 AbstractBaseUserの型定義ではis_activeというbool型のアトリビュートを定義している ため、衝突してエラーになりました。

Slide 49

Slide 49 text

def is_active(self) -> str: # type: ignore return self.status == User.Status.ACTIVE 49 blog/models.py:77: error: Incompatible return value type (got "bool", expected "str") ● 名前が衝突したためのエラーは解消。 ● メソッドの返り値など、処理の内容の誤りはチェックしてくれています。 -> カスタムメソッドに # type: ignore をつけることに。 ※影響範囲が広かったので # type: ignoreにしたけどメソッド名を変えるのが一番良いと思います。 ※そもそも、同じ名前のメソッド・アトリビュートをつけないようにしましょう。

Slide 50

Slide 50 text

RelatedObjectDoesNotExist class Profile(models.Model): user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) comment = models.CharField(max_length=255) birthday = models.DateField() def f(user: User) -> Profile: try: return user.profile except User.profile.RelatedObjectDoesNotExist: ... 50

Slide 51

Slide 51 text

blog/models.py:130: error: "Profile" has no attribute "RelatedObjectDoesNotExist" 51 Issueもあります!(17日前にあげられた) https://github.com/typeddjango/django-stubs/issues/649

Slide 52

Slide 52 text

すぐfixされなさそうなので type: ignore! def f(user: User) -> Profile: try: return user.profile except User.profile.RelatedObjectDoesNotExist: # type: ignore ... 52

Slide 53

Slide 53 text

Querysetのvaluesやvalues_list def f() -> List[int]: # [1, 2, 3, 4]のようなオブジェクトが返されます。 return User.objects.values_list('id', flat=True) 53 blog/models.py:123: error: Incompatible return value type (got "ValuesQuerySet[User, int]", expected "List[int]")

Slide 54

Slide 54 text

そうおっしゃるならやってやるさ from django.db.models.query import ValuesQuerySet def f() -> ValuesQuerySet[User, int]: # [1, 2, 3, 4]のようなオブジェクトが返されます。 return User.objects.values_list('id', flat=True) 54

Slide 55

Slide 55 text

えっ…😇 >> mypy blog/models.py ImportError: cannot import name 'ValuesQuerySet' from 'django.db.models.query' (/home/mizzsugar/workspace/djangocongress2021/.venv/lib/python3.9/site-pa ckages/django/db/models/query.py) 55

Slide 56

Slide 56 text

もはやなくなっているとは…🤣 56 https://github.com/typeddjango/django-stubs/issues/144 「ValuesQuerySetを使おうとし たらDjango1.9でなくなっている んだけど…」 という2019年8月に 書き込まれたIssue

Slide 57

Slide 57 text

回避法があるようだが https://github.com/typeddjango/django-stubs/issues/144 57

Slide 58

Slide 58 text

listにすることにした 58 from typing import List def f() -> List[int]: # django-stubsからValuesQuerySet[User, int]が返されるはずと # 指摘されるが、ValuesQuerySetはDjango1.9で削除されているため # 定義出来ません。 # listにしたいわけじゃないけど型違反を起こさないためにこうしています。 # https://github.com/typeddjango/django-stubs/issues/144 return list(User.objects.values_list('id', flat=True)) 別にlistにしなくてもよいけど仕方なくという実装。 仕方なし実装をする時にコメント書くようにしていることは、快適に開発するための工夫

Slide 59

Slide 59 text

より快適に型ヒント付けを進めるためにしていること ● エディタ・IDEを味方に ● ボーイスカウトルール 59

Slide 60

Slide 60 text

エディタ・IDEを味方に Pythonの型ヒントの支援機能を搭載しているエディタ・IDEを使うと mypyコマンドを走らせなくても誤りを指摘してくれます。 60

Slide 61

Slide 61 text

型ヒント支援機能を搭載しているエディタ・IDE VSCode: Pylance https://marketplace.visualstudio.com/items?itemName=ms-python.vscode-pylance PyCharm https://pleiades.io/help/pycharm/type-hinting-in-product.html 61

Slide 62

Slide 62 text

ボーイスカウト・ルール ● 「プログラマが知るべき97のこと」のうちのひとつ。 ● 「キャンプ場を汚したのが自分でなくても、来た時よりもきれいにしてからその場を 去る」というルール。 ● プログラムの世界では、「既存のソースコードを修正する時に、変数名を適切な名 前に変えるなどの些細な配慮をしよう」という話。 ● チームメンバー全員がそのようなマインドで取り組むことで より高品質ソースコードになるのでは。 ● 型ヒントがついていないところがあったら積極的につけていこう! https://bit.ly/2StEZmz 62

Slide 63

Slide 63 text

ありがとうございました!! 63

Slide 64

Slide 64 text

おまけコーナー ● QuerySetのエラー解消はいつから? ● --strictとDjangoORM ● QuerySetは何型? 64

Slide 65

Slide 65 text

おまけ1: QuerySetのエラー解消はいつから? かつて、こう書いていたらエラーになっていました… (Python 3.8 + Django 2.2使っていた頃) 65 def f() -> models.QuerySet[Post]: return Post.objects.all() python manage.py runserver ... TypeError: 'type' object is not subscriptable __class_getitem__という マジックメソッドがないと subscriptableなクラスと みなされず T[V]という風に定義すると 実行時にエラーになります。 QuerySetには __class_getitem__が 実装されていませんでした。

Slide 66

Slide 66 text

Django 3.1で__class_getitem__が実装されました🎉 66 https://github.com/django/django/commit/578c03b276e435bcd3ce9eb17b81e85135c2d3f3#diff-d58ef61559dc7af5fdf7b56fee13571a4d29 48e784cd608f6afeacf3ac2fb195

Slide 67

Slide 67 text

〜Django 2系での対処方法 1 文字列にして定義 実行時には無視され、型ヒントチェック時には評価されるように 。 67 def f() -> 'models.QuerySet[Post]': return Post.objects.all() https://github.com/typeddjango/django-stubs#i-cannot-use-queryset-or-manager-with-type-annotations

Slide 68

Slide 68 text

〜Django 2系での対処方法 2 django-stubs-extの力を借りる ※__class_getitem__が実装された、Python3.7以上のプロジェクトでないと使えないので注意 68 import django_stubs_ext django_stubs_ext.monkeypatch() settings.py https://github.com/typeddjango/django-stubs#i-cannot-use-queryset-or-manager-with-type-annotations

Slide 69

Slide 69 text

おまけ2: --strictとDjangoORM class PostQuerySet(models.QuerySet): … class PostManager(models.Manager): … class Post(models.Model): objects = PostManager() ... 69

Slide 70

Slide 70 text

blog/models.py:81: error: Missing type parameters for generic type "QuerySet" blog/models.py:91: error: Missing type parameters for generic type "Manager" 70

Slide 71

Slide 71 text

おまけ2: --strictとDjangoORM class PostQuerySet(models.QuerySet[Post]): … class PostManager(models.Manager[Post]): … class Post(models.Model): objects = PostManager() ... 71 Postというクラスなんぞ 知らないというエラーになる

Slide 72

Slide 72 text

文字列で定義! 72 class PostQuerySet(models.QuerySet['Post']): … class PostManager(models.Manager['Post']): ...

Slide 73

Slide 73 text

おまけ3: QuerySetは何型? 73 >>> isinstance(query, List) False >>> isinstance(query, Sequence) False >>> isinstance(query, Collection) False >>> isinstance(query, Iterable) True >>> isinstance(query, Sized) True

Slide 74

Slide 74 text

おまけ3: QuerySetは何型? 結論: QuerySetはIterableかつSizedなオブジェクトらしい 74