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

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

Sponsored · Your Podcast. Everywhere. Effortlessly. Share. Educate. Inspire. Entertain. You do you. We'll handle the rest.

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

Avatar for mizzsugar

mizzsugar

July 03, 2021
Tweet

More Decks by mizzsugar

Other Decks in Programming

Transcript

  1. お前、誰よ? • Twitterはこのアイコン (@mizzsugar0425)→ • Python歴3年 • Spready株式会社 (we are

    hiring!!) • 好き: コーヒー、自転車、ビール • 静岡住みでフルリモートで就業中 3
  2. 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
  3. Djangoでの使い方 3 mypy.iniに静的解析する内容を記載します。 mysite/mypy.ini 14 [mypy] plugins = mypy_django_plugin.main [mypy.plugins.django-stubs]

    django_settings_module = "mysite.settings" 複数設定モジュールがある場合は トップレベルのモジュールを指定
  4. 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
  5. 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
  6. 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"
  7. 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())
  8. 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()
  9. 喝を入れるなど • 大丈夫? 生きてる? • 水飲もうね • 明日はJIMOTOフラペ! 蕎麦! そしてお土産に日本酒とワインを!

    • 蕎麦といえば静岡の戸隠そばも美味しいから静岡にもぜひ来てね • ガッツで後半乗り切ろうね 21
  10. 型ヒント運用開始前のSpready • 型ヒントや自動テストの文化がありませんでした。 • 仕様の知識が属人化されていました。 • ビジネスロジックがviewとmodelに変則的に散らばっており、処理を追うのが困難 でした。 • 型違反による不具合が起こりやすい箇所がちらほら。

    「get_time」という関数の返り値が datetime.dateような • 処理を理解するために隅から隅までソースコードを読まないといけません。 • 自動テストがないので変更のための動作確認に時間がかかります。 25
  11. ディレクトリ構成 27 発表用のために一部省略しています。 app ├── spready │ ├── admin.py │

    ├── apps.py │ ├── migrations │ ├── models │ ├── utils (Django関係ないライブラリ群) │ └── views ├── manage.py └── settings ├── settings.py ├── urls.py └── wsgi.py
  12. 新・ディレクトリ構成 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
  13. 新・ディレクトリ構成 30 発表用のために一部省略しています。 usecases ├── spreader │ ├── project.py (案件探しやタグ付け)

    │ ├── casting.py (携わる案件マッチング) └── company ├── contract.py (契約周り) ├── project.py (案件作成・編集) └── casting.py (案件の協力者マッチング)
  14. gitのpre-commitフックを使う? 1 gitのpre-commitフックとは… • git commit をフックにして実際にcommitする前に 指定されたスクリプトを実行する機能。 • git

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

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

    ignore またはcastを書かないといけなくなります。 • まずは自分たちが定義した関数・メソッドに正しい型を明示することが大事。 -> デフォルトの設定で足りそう! チームの方針としては --strict オプションなしで! ※個々人で頑張ってみるのはOK󰢐 --disallow-untyped-defs など標準でチェックされない項目だが 指摘されたい項目はmypy.iniに追加。 39
  18. 継承元の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]型で す。
  19. 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
  20. 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
  21. ユーザー定義メソッドを追加 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
  22. blog/models.py:76: error: Signature of "is_active" incompatible with supertype "AbstractBaseUser" 48

    AbstractBaseUserの型定義ではis_activeというbool型のアトリビュートを定義している ため、衝突してエラーになりました。
  23. 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にしたけどメソッド名を変えるのが一番良いと思います。 ※そもそも、同じ名前のメソッド・アトリビュートをつけないようにしましょう。
  24. 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
  25. すぐfixされなさそうなので type: ignore! def f(user: User) -> Profile: try: return

    user.profile except User.profile.RelatedObjectDoesNotExist: # type: ignore ... 52
  26. 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]")
  27. そうおっしゃるならやってやるさ 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
  28. えっ…😇 >> 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
  29. 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にしなくてもよいけど仕方なくという実装。 仕方なし実装をする時にコメント書くようにしていることは、快適に開発するための工夫
  30. おまけ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__が 実装されていませんでした。
  31. 〜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
  32. おまけ2: --strictとDjangoORM class PostQuerySet(models.QuerySet[Post]): … class PostManager(models.Manager[Post]): … class Post(models.Model):

    objects = PostManager() ... 71 Postというクラスなんぞ 知らないというエラーになる
  33. おまけ3: QuerySetは何型? 73 >>> isinstance(query, List) False >>> isinstance(query, Sequence)

    False >>> isinstance(query, Collection) False >>> isinstance(query, Iterable) True >>> isinstance(query, Sized) True