Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Djangoでのプロジェクトだって型ヒントを運用出来る!

 Djangoでのプロジェクトだって型ヒントを運用出来る!

C98d379da6e5517afff697a6c5615e68?s=128

mizzsugar

July 03, 2021
Tweet

Transcript

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

  2. Agenda • Pythonで型ヒント使うメリット・デメリット • Djangoでの型ヒントの使い方 • Django製プロジェクトの開発現場の型ヒント ◦ 導入前に決めたこと ◦

    実際に使ってみて困ったこと ◦ より快適に型ヒントを運用するために • おまけコーナー 2
  3. お前、誰よ? • Twitterはこのアイコン (@mizzsugar0425)→ • Python歴3年 • Spready株式会社 (we are

    hiring!!) • 好き: コーヒー、自転車、ビール • 静岡住みでフルリモートで就業中 3
  4. Spready株式会社について 設立: 2018-05-01 ミッション: やりたいに出会い続ける世界をつくる ビジョン: 人と組織の新しい“つながり”をつくる 4

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

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

  7. 前提 • 型ヒントの文法については説明しません。 • 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
  8. 本発表の対象者 • Pythonでの型ヒントの基本的な文法を知っている人。 • Django Tutorialの内容を理解しているレベルの人。 • Djangoのプロジェクトで型ヒントを使いたい人。 • チーム開発でDjangoと型ヒントの折り合いをどこまでつけたかの

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

    9
  10. Pythonでの型ヒントとは? • typingモジュールがPython 3.5で追加されました。 • つけるかつけないかは任意。 10 def add(a: int,

    b: int) -> int: """aとbを足し合わせた数字を返します。 """ return a + b
  11. 型ヒントのメリット・デメリット メリット • 型違反による不具合を防げます。 • 静的解析に裏付けられているとコードリーディング/リファクタリングが より捗ります。 デメリット • 間違った型ヒントでもプログラムは動くので返って混乱の原因になるかも。

    • 型ヒントに不慣れなメンバーのみで構成されていると開発速度が遅くなるかも。 11
  12. Djangoでの使い方 1 pip install mypy django-stubs 12 mypy Pythonでの型ヒントが正しいかどうか解析するツール django-stubs

    Djangoに型を提供している、mypyのプラグイン
  13. 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
  14. Djangoでの使い方 3 mypy.iniに静的解析する内容を記載します。 mysite/mypy.ini 14 [mypy] plugins = mypy_django_plugin.main [mypy.plugins.django-stubs]

    django_settings_module = "mysite.settings" 複数設定モジュールがある場合は トップレベルのモジュールを指定
  15. 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
  16. 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
  17. 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"
  18. 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())
  19. 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()
  20. 休憩 20

  21. 喝を入れるなど • 大丈夫? 生きてる? • 水飲もうね • 明日はJIMOTOフラペ! 蕎麦! そしてお土産に日本酒とワインを!

    • 蕎麦といえば静岡の戸隠そばも美味しいから静岡にもぜひ来てね • ガッツで後半乗り切ろうね 21
  22. Spready株式会社での型ヒント運用 22

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

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

  25. 型ヒント運用開始前のSpready • 型ヒントや自動テストの文化がありませんでした。 • 仕様の知識が属人化されていました。 • ビジネスロジックがviewとmodelに変則的に散らばっており、処理を追うのが困難 でした。 • 型違反による不具合が起こりやすい箇所がちらほら。

    「get_time」という関数の返り値が datetime.dateような • 処理を理解するために隅から隅までソースコードを読まないといけません。 • 自動テストがないので変更のための動作確認に時間がかかります。 25
  26. ソースコードを改善することに決めた理由 • 現状のまま実装を進めてもスピードは上がらないこと。 • まだまだ変更や新規開発が必要なフェーズでスピードが必要。 • ソースコードの可読性を上げないとスピードが上がらないと判断。 26

  27. ディレクトリ構成 27 発表用のために一部省略しています。 app ├── spready │ ├── admin.py │

    ├── apps.py │ ├── migrations │ ├── models │ ├── utils (Django関係ないライブラリ群) │ └── views ├── manage.py └── settings ├── settings.py ├── urls.py └── wsgi.py
  28. ソースコード改善の方針 • usecasesモジュールを作成し、ビジネスロジックを集結させることで 処理の内容追いやすくします。 • usecasesモジュールを機能毎のサブモジュールに分割します。 28

  29. 新・ディレクトリ構成 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
  30. 新・ディレクトリ構成 30 発表用のために一部省略しています。 usecases ├── spreader │ ├── project.py (案件探しやタグ付け)

    │ ├── casting.py (携わる案件マッチング) └── company ├── contract.py (契約周り) ├── project.py (案件作成・編集) └── casting.py (案件の協力者マッチング)
  31. 新・ディレクトリ構成 31 views usecases models utils

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

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

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

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

  36. gitのpre-commitフックを使う? 1 gitのpre-commitフックとは… • git commit をフックにして実際にcommitする前に 指定されたスクリプトを実行する機能。 • git

    commit をフックで型チェックをして、エラーがあったら実際にcommitされない仕 組みを作れます。 https://bit.ly/3AvTGXJ 36
  37. gitのpre-commitフックを使う? 2 メリット • CIにmypyを組み込んでいなくてもコミットしたら 自動的にチェックしてくれます。 デメリット • モジュールごとのチェックなので、コミット内容と関係ない行のエラーも残ります。 •

    エラーがあっても強制的にコミット出来る機能があります。 しかし、強制コミットする運用なのは望ましくありません。 →エラー残した状態で強制コミットは避けたいが 関係ないところも直してコミットするのはレビューワーが大変。 ということで、pre-commitで型チェックしないことに。 37
  38. --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 ドキュメントで「デフォルトで指摘されるのはこれ」と明示されているわけではないのでまだあるかも …。
  39. --strictオプションを使う? 2 • --strictオプションとDjangoORMの親和性󰢄(だと知識が不足していた当時は思っ ていました)(今回の発表で調べているうちに解決したから、おまけコーナーに書きま した!) -> 何回も # type:

    ignore またはcastを書かないといけなくなります。 • まずは自分たちが定義した関数・メソッドに正しい型を明示することが大事。 -> デフォルトの設定で足りそう! チームの方針としては --strict オプションなしで! ※個々人で頑張ってみるのはOK󰢐 --disallow-untyped-defs など標準でチェックされない項目だが 指摘されたい項目はmypy.iniに追加。 39
  40. CIで型チェクするのはいつから? • mypyにチェックしてもらえる最小単位はモジュール。 • utilsの1モジュールだけから? -> utilsにあるのは6モジュールほど →utilsのすべてのモジュールを一気に型ヒントを与えてからスタート。 • 徐々にmodels,

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

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

  43. 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
  44. 継承元の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]型で す。
  45. 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
  46. 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
  47. ユーザー定義メソッドを追加 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
  48. blog/models.py:76: error: Signature of "is_active" incompatible with supertype "AbstractBaseUser" 48

    AbstractBaseUserの型定義ではis_activeというbool型のアトリビュートを定義している ため、衝突してエラーになりました。
  49. 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にしたけどメソッド名を変えるのが一番良いと思います。 ※そもそも、同じ名前のメソッド・アトリビュートをつけないようにしましょう。
  50. 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
  51. blog/models.py:130: error: "Profile" has no attribute "RelatedObjectDoesNotExist" 51 Issueもあります!(17日前にあげられた) https://github.com/typeddjango/django-stubs/issues/649

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

    user.profile except User.profile.RelatedObjectDoesNotExist: # type: ignore ... 52
  53. 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]")
  54. そうおっしゃるならやってやるさ 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
  55. えっ…😇 >> 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
  56. もはやなくなっているとは…🤣 56 https://github.com/typeddjango/django-stubs/issues/144 「ValuesQuerySetを使おうとし たらDjango1.9でなくなっている んだけど…」 という2019年8月に 書き込まれたIssue

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

  58. 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にしなくてもよいけど仕方なくという実装。 仕方なし実装をする時にコメント書くようにしていることは、快適に開発するための工夫
  59. より快適に型ヒント付けを進めるためにしていること • エディタ・IDEを味方に • ボーイスカウトルール 59

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

  61. 型ヒント支援機能を搭載しているエディタ・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

  62. ボーイスカウト・ルール • 「プログラマが知るべき97のこと」のうちのひとつ。 • 「キャンプ場を汚したのが自分でなくても、来た時よりもきれいにしてからその場を 去る」というルール。 • プログラムの世界では、「既存のソースコードを修正する時に、変数名を適切な名 前に変えるなどの些細な配慮をしよう」という話。 •

    チームメンバー全員がそのようなマインドで取り組むことで より高品質ソースコードになるのでは。 • 型ヒントがついていないところがあったら積極的につけていこう! https://bit.ly/2StEZmz 62
  63. ありがとうございました!! 63

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

  65. おまけ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__が 実装されていませんでした。
  66. Django 3.1で__class_getitem__が実装されました🎉 66 https://github.com/django/django/commit/578c03b276e435bcd3ce9eb17b81e85135c2d3f3#diff-d58ef61559dc7af5fdf7b56fee13571a4d29 48e784cd608f6afeacf3ac2fb195

  67. 〜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
  68. 〜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
  69. おまけ2: --strictとDjangoORM class PostQuerySet(models.QuerySet): … class PostManager(models.Manager): … class Post(models.Model):

    objects = PostManager() ... 69
  70. 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
  71. おまけ2: --strictとDjangoORM class PostQuerySet(models.QuerySet[Post]): … class PostManager(models.Manager[Post]): … class Post(models.Model):

    objects = PostManager() ... 71 Postというクラスなんぞ 知らないというエラーになる
  72. 文字列で定義! 72 class PostQuerySet(models.QuerySet['Post']): … class PostManager(models.Manager['Post']): ...

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

    False >>> isinstance(query, Collection) False >>> isinstance(query, Iterable) True >>> isinstance(query, Sized) True
  74. おまけ3: QuerySetは何型? 結論: QuerySetはIterableかつSizedなオブジェクトらしい 74