diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index edd7170..89ddb04 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -78,4 +78,3 @@ repos: entry: mypy language: system types_or: [python, pyi] - exclude: tasks.py$ diff --git a/poetry.lock b/poetry.lock index 3ec4d62..b8693ae 100644 --- a/poetry.lock +++ b/poetry.lock @@ -253,6 +253,35 @@ Django = ">=2.2" phonenumbers = ["phonenumbers (>=7.0.2)"] phonenumberslite = ["phonenumberslite (>=7.0.2)"] +[[package]] +name = "django-stubs" +version = "1.9.0" +description = "Mypy stubs for Django" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +django = "*" +django-stubs-ext = ">=0.3.0" +mypy = ">=0.910" +toml = "*" +types-pytz = "*" +types-PyYAML = "*" +typing-extensions = "*" + +[[package]] +name = "django-stubs-ext" +version = "0.3.1" +description = "Monkey-patching and extensions for django-stubs" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +django = "*" +typing-extensions = "*" + [[package]] name = "django-two-factor-auth" version = "1.13" @@ -807,6 +836,22 @@ category = "dev" optional = false python-versions = "*" +[[package]] +name = "types-pytz" +version = "2021.3.3" +description = "Typing stubs for pytz" +category = "dev" +optional = false +python-versions = "*" + +[[package]] +name = "types-pyyaml" +version = "6.0.1" +description = "Typing stubs for PyYAML" +category = "dev" +optional = false +python-versions = "*" + [[package]] name = "types-requests" version = "2.26.3" @@ -930,7 +975,7 @@ multidict = ">=4.0" [metadata] lock-version = "1.1" python-versions = "^3.10" -content-hash = "17e06d5348b12d6c12a95b1a9ba60278cddae6d15f80d4eba7173a67e5b5b123" +content-hash = "35d359b39bfd7c907a1119797a4badf33b3f3c964a5ec90e85c781cf99fecf7e" [metadata.files] asgiref = [ @@ -1096,6 +1141,14 @@ django-phonenumber-field = [ {file = "django-phonenumber-field-5.2.0.tar.gz", hash = "sha256:52b2e5970133ec5ab701218b802f7ab237229854dc95fd239b7e9e77dc43731d"}, {file = "django_phonenumber_field-5.2.0-py3-none-any.whl", hash = "sha256:5547fb2b2cc690a306ba77a5038419afc8fa8298a486fb7895008e9067cc7e75"}, ] +django-stubs = [ + {file = "django-stubs-1.9.0.tar.gz", hash = "sha256:664843091636a917faf5256d028476559dc360fdef9050b6df87ab61b21607bf"}, + {file = "django_stubs-1.9.0-py3-none-any.whl", hash = "sha256:59c9f81af64d214b1954eaf90f037778c8d2b9c2de946a3cda177fefcf588fbd"}, +] +django-stubs-ext = [ + {file = "django-stubs-ext-0.3.1.tar.gz", hash = "sha256:783c198d7e39a41be0b90fd843fa2770243a642922af679be4b19e03b82c8c28"}, + {file = "django_stubs_ext-0.3.1-py3-none-any.whl", hash = "sha256:a51a3e9e844d4e1cacaaedbb33bf3def78a3956eed5d9575a640bd97ccd99cec"}, +] django-two-factor-auth = [] filelock = [ {file = "filelock-3.4.2-py3-none-any.whl", hash = "sha256:cf0fc6a2f8d26bd900f19bf33915ca70ba4dd8c56903eeb14e1e7a2fd7590146"}, @@ -1525,6 +1578,14 @@ types-pillow = [ {file = "types-Pillow-8.3.11.tar.gz", hash = "sha256:aa96a739184f48f69e6f30218400623fc5a95f5fec199c447663a32538440405"}, {file = "types_Pillow-8.3.11-py3-none-any.whl", hash = "sha256:998189334e616b1dd42c9634669efbf726184039e96e9a23ec95246e0ecff3fc"}, ] +types-pytz = [ + {file = "types-pytz-2021.3.3.tar.gz", hash = "sha256:f6d21d6687935a1615db464b1e1df800d19502c36bc0486f43be7dfd2c404947"}, + {file = "types_pytz-2021.3.3-py3-none-any.whl", hash = "sha256:75859c64c9a97d68259af6da208e8f5aaf4be4536e4d431a82a6e8b848fc183d"}, +] +types-pyyaml = [ + {file = "types-PyYAML-6.0.1.tar.gz", hash = "sha256:2e27b0118ca4248a646101c5c318dc02e4ca2866d6bc42e84045dbb851555a76"}, + {file = "types_PyYAML-6.0.1-py3-none-any.whl", hash = "sha256:d5b318269652e809b5c30a5fe666c50159ab80bfd41cd6bafe655bf20b29fcba"}, +] types-requests = [ {file = "types-requests-2.26.3.tar.gz", hash = "sha256:d63fa617846dcefff5aa2d59e47ab4ffd806e4bb0567115f7adbb5e438302fe4"}, {file = "types_requests-2.26.3-py3-none-any.whl", hash = "sha256:ad18284931c5ddbf050ccdd138f200d18fd56f88aa3567019d8da9b2d4fe0344"}, diff --git a/pre-commit.Dockerfile b/pre-commit.Dockerfile deleted file mode 100644 index f9833da..0000000 --- a/pre-commit.Dockerfile +++ /dev/null @@ -1,2 +0,0 @@ -FROM python:3.8.6-buster -RUN python3 -m pip install pre-commit==2.9.3 diff --git a/pyproject.toml b/pyproject.toml index f594f43..58147a4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ types-setuptools = "^57.4.5" types-toml = "^0.10.1" types-beautifulsoup4 = "^4.10.7" types-Pillow = "^8.3.11" +django-stubs = "^1.9.0" [tool.black] target-version = ['py38'] @@ -63,6 +64,16 @@ env = [ [tool.mypy] mypy_path = "$MYPY_CONFIG_FILE_DIR/stubs:$MYPY_CONFIG_FILE_DIR/src" +plugins = ["mypy_django_plugin.main"] +disallow_untyped_defs = true +warn_redundant_casts = true +strict_equality = true +disallow_untyped_calls = true +warn_unreachable = true +enable_error_code = ["redundant-expr", "truthy-bool"] + +[tool.django-stubs] +django_settings_module = "blog.settings" [[tool.mypy.overrides]] module = [ @@ -71,7 +82,8 @@ module = [ "django_otp.plugins.otp_static.models", "two_factor.models", "django_otp.plugins.otp_totp.models", - "model_bakery" + "model_bakery", + "invoke", ] ignore_missing_imports = true diff --git a/src/articles/admin.py b/src/articles/admin.py index 2776663..40f3ef6 100644 --- a/src/articles/admin.py +++ b/src/articles/admin.py @@ -1,6 +1,10 @@ +from typing import cast + from django.contrib import admin, messages from django.contrib.admin import register from django.contrib.auth.admin import UserAdmin +from django.db.models import QuerySet +from django.http import HttpRequest, HttpResponse, HttpResponseRedirect from django.shortcuts import redirect from .models import Article, Tag, User @@ -73,13 +77,13 @@ class ArticleAdmin(admin.ModelAdmin): autocomplete_fields = ["tags"] show_full_result_count = False - def get_queryset(self, request): + def get_queryset(self, request: HttpRequest) -> QuerySet: queryset = super().get_queryset(request) queryset = queryset.prefetch_related("tags") return queryset @admin.action(description="Publish selected articles") - def publish(self, request, queryset): + def publish(self, request: HttpRequest, queryset: QuerySet) -> None: if not request.user.has_perm("articles.change_article"): messages.warning(request, "You're not allowed to do this.") return @@ -88,7 +92,7 @@ class ArticleAdmin(admin.ModelAdmin): messages.success(request, f"{len(queryset)} articles published.") @admin.action(description="Unpublish selected articles") - def unpublish(self, request, queryset): + def unpublish(self, request: HttpRequest, queryset: QuerySet) -> None: if not request.user.has_perm("articles.change_article"): messages.warning(request, "You're not allowed to do this.") return @@ -97,7 +101,7 @@ class ArticleAdmin(admin.ModelAdmin): messages.success(request, f"{len(queryset)} articles unpublished.") @admin.action(description="Refresh draft key of selected articles") - def refresh_draft_key(self, request, queryset): + def refresh_draft_key(self, request: HttpRequest, queryset: QuerySet) -> None: if not request.user.has_perm("articles.change_article"): messages.warning(request, "You're not allowed to do this.") return @@ -110,12 +114,14 @@ class ArticleAdmin(admin.ModelAdmin): class Media: css = {"all": ("admin_articles.css",)} - def response_post_save_add(self, request, obj: Article): + def response_post_save_add( + self, request: HttpRequest, obj: Article + ) -> HttpResponseRedirect: if "_preview" in request.POST: - return redirect("article-detail", slug=obj.slug) + return cast(HttpResponseRedirect, redirect("article-detail", slug=obj.slug)) return super().response_post_save_add(request, obj) - def response_change(self, request, obj: Article): + def response_change(self, request: HttpRequest, obj: Article) -> HttpResponse: if "_preview" in request.POST: obj.save() return redirect("article-detail", slug=obj.slug) @@ -129,11 +135,11 @@ class ArticleAdmin(admin.ModelAdmin): return redirect(".") return super().response_change(request, obj) - def read_time(self, instance: Article): + def read_time(self, instance: Article) -> str: return f"{instance.get_read_time()} min" @admin.display(boolean=True) - def has_custom_css(self, instance: Article): + def has_custom_css(self, instance: Article) -> bool: return bool(instance.custom_css) diff --git a/src/articles/markdown.py b/src/articles/markdown.py index acefada..1c44e59 100644 --- a/src/articles/markdown.py +++ b/src/articles/markdown.py @@ -9,15 +9,15 @@ from markdown.inlinepatterns import ( class LazyImageInlineProcessor(ImageInlineProcessor): - def handleMatch(self, m, data): + def handleMatch(self, m, data): # type: ignore el, match_start, index = super().handleMatch(m, data) if el is not None: el.set("loading", "lazy") - return el, match_start, index + return el, match_start, index # type: ignore class LazyImageReferenceInlineProcessor(ImageReferenceInlineProcessor): - def makeTag(self, href, title, text): + def makeTag(self, href, title, text): # type: ignore el = super().makeTag(href, title, text) if el is not None: el.set("loading", "lazy") @@ -25,7 +25,7 @@ class LazyImageReferenceInlineProcessor(ImageReferenceInlineProcessor): class LazyLoadingImageExtension(Extension): - def extendMarkdown(self, md: Markdown): + def extendMarkdown(self, md: Markdown) -> None: md.inlinePatterns.register( LazyImageInlineProcessor(IMAGE_LINK_RE, md), "image_link", 150 ) diff --git a/src/articles/migrations/0024_auto_20201224_1746.py b/src/articles/migrations/0024_auto_20201224_1746.py index 067660e..99e4c26 100644 --- a/src/articles/migrations/0024_auto_20201224_1746.py +++ b/src/articles/migrations/0024_auto_20201224_1746.py @@ -1,15 +1,16 @@ # Generated by Django 3.1.3 on 2020-12-24 16:46 - +from django.apps.registry import Apps from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor -def forwards_func(apps, schema_editor): +def forwards_func(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: Article = apps.get_model("articles", "Article") db_alias = schema_editor.connection.alias Article.objects.using(db_alias).filter(slug="about-me").update(is_home=True) -def reverse_func(apps, schema_editor): +def reverse_func(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: pass diff --git a/src/articles/migrations/0027_auto_20210303_1633.py b/src/articles/migrations/0027_auto_20210303_1633.py index b950f18..b68c4fc 100644 --- a/src/articles/migrations/0027_auto_20210303_1633.py +++ b/src/articles/migrations/0027_auto_20210303_1633.py @@ -1,15 +1,17 @@ # Generated by Django 3.1.5 on 2021-03-03 15:33 - +from django.apps.registry import Apps from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor -def forwards(apps, schema_editor): +def forwards(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: Tag = apps.get_model("articles", "Tag") Article = apps.get_model("articles", "Article") db_alias = schema_editor.connection.alias articles = Article.objects.using(db_alias).all() for article in articles: tags = [] + keyword: str for keyword in list( filter(None, map(lambda k: k.strip(), article.keywords.split(","))) ): @@ -22,7 +24,7 @@ def forwards(apps, schema_editor): Article.objects.bulk_update(articles, ["keywords"]) -def backwards(apps, schema_editor): +def backwards(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: Article = apps.get_model("articles", "Article") db_alias = schema_editor.connection.alias articles = Article.objects.using(db_alias).all() diff --git a/src/articles/migrations/0030_tag_slug.py b/src/articles/migrations/0030_tag_slug.py index d74517a..f2aa77f 100644 --- a/src/articles/migrations/0030_tag_slug.py +++ b/src/articles/migrations/0030_tag_slug.py @@ -1,10 +1,11 @@ # Generated by Django 3.1.5 on 2021-03-04 17:17 - +from django.apps.registry import Apps from django.db import migrations, models +from django.db.backends.base.schema import BaseDatabaseSchemaEditor from django.utils.text import slugify -def forwards(apps, schema_editor): +def forwards(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: Tag = apps.get_model("articles", "Tag") db_alias = schema_editor.connection.alias tags = Tag.objects.using(db_alias).all() @@ -13,7 +14,7 @@ def forwards(apps, schema_editor): Tag.objects.bulk_update(tags, ["slug"]) -def backwards(apps, schema_editor): +def backwards(apps: Apps, schema_editor: BaseDatabaseSchemaEditor) -> None: Tag = apps.get_model("articles", "Tag") db_alias = schema_editor.connection.alias Tag.objects.using(db_alias).update(slug="") diff --git a/src/articles/models.py b/src/articles/models.py index 2da61c4..015eb51 100644 --- a/src/articles/models.py +++ b/src/articles/models.py @@ -3,7 +3,7 @@ from __future__ import annotations import random import uuid from functools import cached_property -from typing import Sequence +from typing import Any, Sequence import rcssmin import readtime @@ -115,7 +115,7 @@ class Article(models.Model): self.save() return self - def save(self, *args, **kwargs) -> None: + def save(self, *args: Any, **kwargs: Any) -> None: if not self.slug: self.slug = slugify(self.title) return super().save(*args, **kwargs) diff --git a/src/articles/tests/conftest.py b/src/articles/tests/conftest.py index 7c0b1ef..2139085 100644 --- a/src/articles/tests/conftest.py +++ b/src/articles/tests/conftest.py @@ -55,5 +55,5 @@ def unpublished_article(author: User) -> Article: @pytest.fixture(autouse=True, scope="session") -def _collect_static(): +def _collect_static() -> None: call_command("collectstatic", "--no-input", "--clear") diff --git a/src/articles/tests/test_admin.py b/src/articles/tests/test_admin.py index af4ab79..8316159 100644 --- a/src/articles/tests/test_admin.py +++ b/src/articles/tests/test_admin.py @@ -8,7 +8,7 @@ from articles.models import User @pytest.mark.django_db() # @pytest.mark.skip("Fails for no apparent reason") @pytest.mark.flaky(reruns=5, reruns_delay=3) -def test_can_access_add_article(client: Client, author: User): +def test_can_access_add_article(client: Client, author: User) -> None: client.force_login(author) url = reverse("admin:articles_article_add") res = client.get(url) diff --git a/src/articles/tests/test_api_views.py b/src/articles/tests/test_api_views.py index 9d05bc0..e5b92c4 100644 --- a/src/articles/tests/test_api_views.py +++ b/src/articles/tests/test_api_views.py @@ -1,4 +1,5 @@ import pytest +from django.http import HttpResponse from django.test import Client from django.urls import reverse @@ -7,7 +8,9 @@ from articles.utils import format_article_content @pytest.mark.django_db() -def test_unauthenticated_render_redirects(published_article: Article, client: Client): +def test_unauthenticated_render_redirects( + published_article: Article, client: Client +) -> None: api_res = client.post( reverse("api-render-article", kwargs={"article_pk": published_article.pk}), data={"content": published_article.content}, @@ -16,7 +19,9 @@ def test_unauthenticated_render_redirects(published_article: Article, client: Cl @pytest.mark.django_db() -def test_render_article_same_content(published_article: Article, client: Client): +def test_render_article_same_content( + published_article: Article, client: Client +) -> None: client.force_login(published_article.author) api_res = post_article(client, published_article, published_article.content) standard_res = client.get( @@ -36,7 +41,9 @@ def test_render_article_same_content(published_article: Article, client: Client) @pytest.mark.django_db() -def test_render_article_change_content(published_article: Article, client: Client): +def test_render_article_change_content( + published_article: Article, client: Client +) -> None: client.force_login(published_article.author) preview_content = "This is a different content **with strong emphasis**" api_res = post_article(client, published_article, preview_content) @@ -47,7 +54,7 @@ def test_render_article_change_content(published_article: Article, client: Clien @pytest.mark.django_db() -def test_render_article_doesnt_save(published_article, client: Client): +def test_render_article_doesnt_save(published_article: Article, client: Client) -> None: client.force_login(published_article.author) original_content = published_article.content preview_content = "This is a different content **with strong emphasis**" @@ -58,7 +65,7 @@ def test_render_article_doesnt_save(published_article, client: Client): @pytest.mark.django_db() -def test_render_article_no_tags(published_article, client: Client): +def test_render_article_no_tags(published_article: Article, client: Client) -> None: client.force_login(published_article.author) api_res = client.post( reverse("api-render-article", kwargs={"article_pk": published_article.pk}), @@ -67,7 +74,7 @@ def test_render_article_no_tags(published_article, client: Client): assert api_res.status_code == 200 -def post_article(client: Client, article: Article, content: str): +def post_article(client: Client, article: Article, content: str) -> HttpResponse: return client.post( reverse("api-render-article", kwargs={"article_pk": article.pk}), data={ diff --git a/src/articles/tests/test_feed_views.py b/src/articles/tests/test_feed_views.py index 1bb90b6..a42a782 100644 --- a/src/articles/tests/test_feed_views.py +++ b/src/articles/tests/test_feed_views.py @@ -8,7 +8,7 @@ from articles.views.feeds import CompleteFeed @pytest.mark.django_db() -def test_can_access_feed(client: Client, published_article): +def test_can_access_feed(client: Client, published_article: Article) -> None: res = client.get(reverse("complete-feed")) assert res.status_code == 200 assert "application/rss+xml" in res["content-type"] @@ -17,7 +17,7 @@ def test_can_access_feed(client: Client, published_article): @pytest.mark.django_db() -def test_feed_limits_number_of_articles(client: Client, author: User): +def test_feed_limits_number_of_articles(client: Client, author: User) -> None: baker.make(Article, 100, status=Article.PUBLISHED, author=author) res = client.get(reverse("complete-feed")) content = res.content.decode("utf-8") diff --git a/src/articles/tests/test_html_views.py b/src/articles/tests/test_html_views.py index 3e424ff..1ece30f 100644 --- a/src/articles/tests/test_html_views.py +++ b/src/articles/tests/test_html_views.py @@ -1,13 +1,15 @@ import pytest +from django.http import HttpResponse from django.test import Client from django.urls import reverse from model_bakery import baker +from pytest_django.fixtures import SettingsWrapper from articles.models import Article, User @pytest.mark.django_db() -def test_can_access_list(client: Client, published_article: Article): +def test_can_access_list(client: Client, published_article: Article) -> None: res = client.get(reverse("articles-list")) assert res.status_code == 200 content = res.content.decode("utf-8") @@ -16,7 +18,7 @@ def test_can_access_list(client: Client, published_article: Article): @pytest.mark.django_db() -def test_only_title_shown_on_list(client: Client, author: User): +def test_only_title_shown_on_list(client: Client, author: User) -> None: title = "This is a very long title mouahahaha" abstract = "Some abstract" after = "Some content after abstract" @@ -35,16 +37,16 @@ def test_only_title_shown_on_list(client: Client, author: User): @pytest.mark.django_db() -def test_access_article_by_slug(client: Client, published_article: Article): +def test_access_article_by_slug(client: Client, published_article: Article) -> None: _test_access_article_by_slug(client, published_article) -def _test_access_article_by_slug(client: Client, item: Article): +def _test_access_article_by_slug(client: Client, item: Article) -> None: res = client.get(reverse("article-detail", kwargs={"slug": item.slug})) _assert_article_is_rendered(item, res) -def _assert_article_is_rendered(item: Article, res): +def _assert_article_is_rendered(item: Article, res: HttpResponse) -> None: assert res.status_code == 200 content = res.content.decode("utf-8") assert item.title in content @@ -55,7 +57,7 @@ def _assert_article_is_rendered(item: Article, res): @pytest.mark.django_db() def test_anonymous_cant_access_draft_detail( client: Client, unpublished_article: Article -): +) -> None: res = client.get( reverse("article-detail", kwargs={"slug": unpublished_article.slug}) ) @@ -65,7 +67,7 @@ def test_anonymous_cant_access_draft_detail( @pytest.mark.django_db() def test_anonymous_can_access_draft_detail_with_key( client: Client, unpublished_article: Article -): +) -> None: res = client.get( reverse("article-detail", kwargs={"slug": unpublished_article.slug}) + f"?draft_key={unpublished_article.draft_key}" @@ -76,7 +78,7 @@ def test_anonymous_can_access_draft_detail_with_key( @pytest.mark.django_db() def test_user_can_access_draft_detail( client: Client, author: User, unpublished_article: Article -): +) -> None: client.force_login(author) _test_access_article_by_slug(client, unpublished_article) @@ -84,7 +86,7 @@ def test_user_can_access_draft_detail( @pytest.mark.django_db() def test_anonymous_cant_access_drafts_list( client: Client, unpublished_article: Article -): +) -> None: res = client.get(reverse("drafts-list")) assert res.status_code == 302 @@ -92,7 +94,7 @@ def test_anonymous_cant_access_drafts_list( @pytest.mark.django_db() def test_user_can_access_drafts_list( client: Client, author: User, unpublished_article: Article -): +) -> None: client.force_login(author) res = client.get(reverse("drafts-list")) assert res.status_code == 200 @@ -101,7 +103,7 @@ def test_user_can_access_drafts_list( @pytest.mark.django_db() -def test_has_goatcounter_if_set(client: Client, settings): +def test_has_goatcounter_if_set(client: Client, settings: SettingsWrapper) -> None: settings.GOATCOUNTER_DOMAIN = "gc.gabnotes.org" res = client.get(reverse("articles-list")) content = res.content.decode("utf-8") @@ -110,7 +112,9 @@ def test_has_goatcounter_if_set(client: Client, settings): @pytest.mark.django_db() -def test_doesnt_have_goatcounter_if_unset(client: Client, settings): +def test_doesnt_have_goatcounter_if_unset( + client: Client, settings: SettingsWrapper +) -> None: settings.GOATCOUNTER_DOMAIN = None res = client.get(reverse("articles-list")) content = res.content.decode("utf-8") @@ -119,7 +123,9 @@ def test_doesnt_have_goatcounter_if_unset(client: Client, settings): @pytest.mark.django_db() -def test_logged_in_user_doesnt_have_goatcounter(client: Client, author: User, settings): +def test_logged_in_user_doesnt_have_goatcounter( + client: Client, author: User, settings: SettingsWrapper +) -> None: client.force_login(author) settings.GOATCOUNTER_DOMAIN = "gc.gabnotes.org" res = client.get(reverse("articles-list")) @@ -129,7 +135,7 @@ def test_logged_in_user_doesnt_have_goatcounter(client: Client, author: User, se @pytest.mark.django_db() -def test_image_is_lazy(client: Client, published_article: Article): +def test_image_is_lazy(client: Client, published_article: Article) -> None: res = client.get(reverse("article-detail", kwargs={"slug": published_article.slug})) assert res.status_code == 200 content = res.content.decode("utf-8") diff --git a/src/articles/tests/test_migrations.py b/src/articles/tests/test_migrations.py index 0b79cd1..cc646c8 100644 --- a/src/articles/tests/test_migrations.py +++ b/src/articles/tests/test_migrations.py @@ -3,5 +3,5 @@ from django.core.management import call_command @pytest.mark.django_db() -def test_missing_migrations(): +def test_missing_migrations() -> None: call_command("makemigrations", "--check") diff --git a/src/articles/tests/test_models.py b/src/articles/tests/test_models.py index 2a0f31b..0b8ef68 100644 --- a/src/articles/tests/test_models.py +++ b/src/articles/tests/test_models.py @@ -4,7 +4,7 @@ from articles.models import Article, User @pytest.mark.django_db() -def test_publish_article(unpublished_article: Article): +def test_publish_article(unpublished_article: Article) -> None: assert unpublished_article.status == Article.DRAFT assert unpublished_article.published_at is None published_article = unpublished_article.publish() @@ -13,7 +13,7 @@ def test_publish_article(unpublished_article: Article): @pytest.mark.django_db() -def test_unpublish_article(published_article: Article): +def test_unpublish_article(published_article: Article) -> None: assert published_article.status == Article.PUBLISHED assert published_article.published_at is not None unpublished_article = published_article.unpublish() @@ -22,7 +22,7 @@ def test_unpublish_article(published_article: Article): @pytest.mark.django_db() -def test_save_article_adds_missing_slug(author: User): +def test_save_article_adds_missing_slug(author: User) -> None: # Explicitly calling bulk_create with one article because it doesn't call save(). articles = Article.objects.bulk_create( [Article(author=author, title="noice title", slug="", status=Article.DRAFT)] @@ -34,7 +34,7 @@ def test_save_article_adds_missing_slug(author: User): @pytest.mark.django_db() -def test_save_article_doesnt_change_existing_slug(published_article: Article): +def test_save_article_doesnt_change_existing_slug(published_article: Article) -> None: original_slug = published_article.slug published_article.title = "This is a brand new title" published_article.save() @@ -42,19 +42,19 @@ def test_save_article_doesnt_change_existing_slug(published_article: Article): @pytest.mark.django_db() -def test_empty_custom_css_minified(published_article): +def test_empty_custom_css_minified(published_article: Article) -> None: published_article.custom_css = "" assert published_article.get_minified_custom_css == "" @pytest.mark.django_db() -def test_simple_custom_css_minified(published_article): +def test_simple_custom_css_minified(published_article: Article) -> None: published_article.custom_css = ".cls {\n background-color: red;\n}" assert published_article.get_minified_custom_css == ".cls{background-color:red}" @pytest.mark.django_db() -def test_larger_custom_css_minified(published_article): +def test_larger_custom_css_minified(published_article: Article) -> None: published_article.custom_css = """\ .profile { display: flex; diff --git a/src/articles/utils.py b/src/articles/utils.py index e45c7ac..033e9bf 100644 --- a/src/articles/utils.py +++ b/src/articles/utils.py @@ -3,13 +3,14 @@ import re import markdown from bs4 import BeautifulSoup from django.conf import settings +from django.http import HttpRequest from markdown.extensions.codehilite import CodeHiliteExtension from markdown.extensions.toc import TocExtension from articles.markdown import LazyLoadingImageExtension -def build_full_absolute_url(request, url: str) -> str: +def build_full_absolute_url(request: HttpRequest | None, url: str) -> str: if request: return request.build_absolute_uri(url) else: diff --git a/src/articles/views/api.py b/src/articles/views/api.py index 5ac6660..a6d41c8 100644 --- a/src/articles/views/api.py +++ b/src/articles/views/api.py @@ -1,7 +1,7 @@ from typing import Any from django.contrib.auth.decorators import login_required -from django.http import HttpResponse +from django.http import HttpRequest, HttpResponse from django.shortcuts import render from django.views.decorators.http import require_POST @@ -10,7 +10,7 @@ from articles.models import Article, Tag @login_required @require_POST -def render_article(request, article_pk: int) -> HttpResponse: +def render_article(request: HttpRequest, article_pk: int) -> HttpResponse: template = "articles/article_detail.html" article = Article.objects.get(pk=article_pk) article.content = request.POST.get("content", article.content) diff --git a/src/articles/views/feeds.py b/src/articles/views/feeds.py index 3f74801..eaa507a 100644 --- a/src/articles/views/feeds.py +++ b/src/articles/views/feeds.py @@ -1,8 +1,9 @@ from datetime import datetime -from typing import Iterable +from typing import Any, Iterable from django.contrib.syndication.views import Feed from django.db.models import QuerySet +from django.http import HttpRequest from articles.models import Article, Tag from blog import settings @@ -33,7 +34,7 @@ class CompleteFeed(BaseFeed): class TagFeed(BaseFeed): - def get_object(self, request, *args, **kwargs) -> Tag: + def get_object(self, request: HttpRequest, *args: Any, **kwargs: Any) -> Tag: return Tag.objects.get(slug=kwargs.get("slug")) def title(self, tag: Tag) -> str: diff --git a/src/articles/views/html.py b/src/articles/views/html.py index fa2859a..1ad75e1 100644 --- a/src/articles/views/html.py +++ b/src/articles/views/html.py @@ -5,7 +5,9 @@ from typing import Any from django.conf import settings from django.contrib.auth.mixins import LoginRequiredMixin from django.core.paginator import Page -from django.db.models import F, Q +from django.db.models import F, Q, QuerySet +from django.http import HttpRequest +from django.http.response import HttpResponseBase from django.shortcuts import get_object_or_404 from django.views import generic from django.views.generic import DetailView @@ -20,7 +22,7 @@ class BaseArticleListView(generic.ListView): main_title = "Blog posts" html_title = "" - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) context["blog_title"] = settings.BLOG["title"] context["blog_description"] = settings.BLOG["description"] @@ -51,7 +53,7 @@ class PublicArticleListView(BaseArticleListView): class ArticlesListView(PublicArticleListView): - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) home_article = Article.objects.filter( status=Article.PUBLISHED, is_home=True @@ -64,12 +66,12 @@ class SearchArticlesListView(PublicArticleListView): template_name = "articles/article_search.html" html_title = "Search" - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) context["search_expression"] = self.request.GET.get("s") or "" return context - def get_queryset(self): + def get_queryset(self) -> QuerySet: queryset = super().get_queryset() search_expression = self.request.GET.get("s") if not search_expression: @@ -98,25 +100,27 @@ class TagArticlesListView(PublicArticleListView): main_title = "" html_title = "" - def dispatch(self, request, *args, **kwargs): + def dispatch( + self, request: HttpRequest, *args: Any, **kwargs: Any + ) -> HttpResponseBase: self.tag = get_object_or_404(Tag, slug=self.kwargs.get("slug")) self.main_title = self.html_title = f"{self.tag.name} articles" return super().dispatch(request, *args, **kwargs) - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) context["feed_title"] = self.tag.get_feed_title() context["feed_url"] = self.tag.get_feed_url() return context - def get_queryset(self): + def get_queryset(self) -> QuerySet: return super().get_queryset().filter(tags=self.tag) class DraftsListView(LoginRequiredMixin, BaseArticleListView): queryset = Article.objects.filter(status=Article.DRAFT) - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: context = super().get_context_data(**kwargs) context["title"] = "Drafts" context["title_header"] = context["title"] @@ -128,7 +132,7 @@ class ArticleDetailView(DetailView): context_object_name = "article" template_name = "articles/article_detail.html" - def get_queryset(self): + def get_queryset(self) -> QuerySet: key = self.request.GET.get("draft_key") if key: return Article.objects.filter(draft_key=key).prefetch_related("tags") @@ -138,7 +142,7 @@ class ArticleDetailView(DetailView): queryset = queryset.filter(status=Article.PUBLISHED) return queryset - def get_object(self, queryset=None) -> Article: + def get_object(self, queryset: QuerySet | None = None) -> Article: obj: Article = super().get_object(queryset) if not self.request.user.is_authenticated: obj.views_count = F("views_count") + 1 @@ -146,6 +150,6 @@ class ArticleDetailView(DetailView): return obj - def get_context_data(self, **kwargs): + def get_context_data(self, **kwargs: Any) -> dict[str, Any]: kwargs["tags"] = self.object.tags.all() return super().get_context_data(**kwargs) diff --git a/src/attachments/admin.py b/src/attachments/admin.py index 8fb53e6..0eb3ccb 100644 --- a/src/attachments/admin.py +++ b/src/attachments/admin.py @@ -1,5 +1,7 @@ from django.contrib import admin, messages from django.contrib.admin import register +from django.db.models import QuerySet +from django.http import HttpRequest from django.utils.html import format_html from attachments.models import Attachment @@ -38,7 +40,7 @@ class AttachmentAdmin(admin.ModelAdmin): class Media: js = ["attachments/js/copy_url.js"] - def processed_file_url(self, instance): + def processed_file_url(self, instance: Attachment) -> str: if instance.processed_file: return format_html( '{0} 📋', @@ -47,7 +49,7 @@ class AttachmentAdmin(admin.ModelAdmin): ) return "" - def original_file_url(self, instance): + def original_file_url(self, instance: Attachment) -> str: if instance.original_file: return format_html( '{0} 📋', @@ -57,7 +59,7 @@ class AttachmentAdmin(admin.ModelAdmin): return "" @admin.action(description="Set as open graph image") - def set_as_open_graph_image(self, request, queryset): + def set_as_open_graph_image(self, request: HttpRequest, queryset: QuerySet) -> None: if len(queryset) != 1: messages.error(request, "You must select only one attachment") return @@ -66,7 +68,9 @@ class AttachmentAdmin(admin.ModelAdmin): messages.success(request, "Done") @admin.action(description="Reprocess selected attachments") - def reprocess_selected_attachments(self, request, queryset): + def reprocess_selected_attachments( + self, request: HttpRequest, queryset: QuerySet + ) -> None: if len(queryset) == 0: messages.error(request, "You must select at least one attachment") return diff --git a/src/attachments/management/commands/reprocess_all_attachments.py b/src/attachments/management/commands/reprocess_all_attachments.py index 9414638..73d4aff 100644 --- a/src/attachments/management/commands/reprocess_all_attachments.py +++ b/src/attachments/management/commands/reprocess_all_attachments.py @@ -1,3 +1,5 @@ +from typing import Any + from django.core.management.base import BaseCommand from attachments.models import Attachment @@ -6,7 +8,7 @@ from attachments.models import Attachment class Command(BaseCommand): help = "Reprocess all attachments" - def handle(self, *args, **options): + def handle(self, *args: Any, **options: Any) -> None: for attachment in Attachment.objects.all(): self.stdout.write(f"Processing {attachment}...") attachment.reprocess() diff --git a/src/attachments/models.py b/src/attachments/models.py index 8936300..8eabca9 100644 --- a/src/attachments/models.py +++ b/src/attachments/models.py @@ -1,19 +1,23 @@ +from __future__ import annotations + import json import tempfile from pathlib import Path +from typing import Any import requests from django.conf import settings from django.core.files import File from django.db import models from django.db.models.fields.files import FieldFile +from django.http import HttpRequest from PIL import Image from articles.utils import build_full_absolute_url class AbsoluteUrlFieldFile(FieldFile): - def get_full_absolute_url(self, request): + def get_full_absolute_url(self, request: HttpRequest) -> str: return build_full_absolute_url(request, self.url) @@ -22,7 +26,7 @@ class AbsoluteUrlFileField(models.FileField): class AttachmentManager(models.Manager): - def get_open_graph_image(self): + def get_open_graph_image(self) -> Attachment | None: return self.filter(open_graph_image=True).first() @@ -37,14 +41,14 @@ class Attachment(models.Model): class Meta: ordering = ["description"] - def __str__(self): + def __str__(self) -> str: return f"{self.description} ({self.original_file.name})" - def reprocess(self): + def reprocess(self) -> None: self.processed_file = None self.save() - def save(self, *args, **kwargs): + def save(self, *args: Any, **kwargs: Any) -> None: super().save(*args, **kwargs) if self.processed_file: diff --git a/src/attachments/tests/conftest.py b/src/attachments/tests/conftest.py index f532ffb..903295b 100644 --- a/src/attachments/tests/conftest.py +++ b/src/attachments/tests/conftest.py @@ -1,13 +1,19 @@ +from typing import Any, Protocol + import pytest -def replace_post_body(request): +class Request(Protocol): + body: Any + + +def replace_post_body(request: Request) -> Request: request.body = None return request @pytest.fixture(scope="module") -def vcr_config(): +def vcr_config() -> dict[str, Any]: return { "before_record_request": replace_post_body, } diff --git a/src/attachments/tests/test_models.py b/src/attachments/tests/test_models.py index 4024081..c4fb608 100644 --- a/src/attachments/tests/test_models.py +++ b/src/attachments/tests/test_models.py @@ -9,7 +9,7 @@ from attachments.models import Attachment @pytest.mark.block_network() @pytest.mark.vcr() @pytest.mark.django_db() -def test_attachment_is_processed_by_shortpixel(): +def test_attachment_is_processed_by_shortpixel() -> None: # This path manipulation is required to make the test run from this directory # or from upper in the hierarchy (e.g.: settings.BASE_DIR) img_path = Path(__file__).parent / "resources" / "image.png" diff --git a/src/blog/tests/test_robots.py b/src/blog/tests/test_robots.py index 36bfbf3..2884cd2 100644 --- a/src/blog/tests/test_robots.py +++ b/src/blog/tests/test_robots.py @@ -1,4 +1,7 @@ -def test_robots_txt(client): +from django.test.client import Client + + +def test_robots_txt(client: Client) -> None: res = client.get("/robots.txt") assert res.status_code == 200 assert res["Content-Type"] == "text/plain" diff --git a/src/manage.py b/src/manage.py index ba0201f..b532cc3 100755 --- a/src/manage.py +++ b/src/manage.py @@ -4,7 +4,7 @@ import os import sys -def main(): +def main() -> None: """Run administrative tasks.""" # noqa: DAR401 os.environ.setdefault("DJANGO_SETTINGS_MODULE", "blog.settings") try: diff --git a/stubs/readtime/result.pyi b/stubs/readtime/result.pyi index 5cc85af..fc91b98 100644 --- a/stubs/readtime/result.pyi +++ b/stubs/readtime/result.pyi @@ -4,7 +4,7 @@ class Result: delta: timedelta | None wpm: int | None def __init__(self, seconds: int | None = ..., wpm: int | None = ...) -> None: ... - def __unicode__(self): ... + def __unicode__(self) -> str: ... @property def seconds(self) -> int: ... @property diff --git a/tasks.py b/tasks.py index b6514dc..21c9148 100644 --- a/tasks.py +++ b/tasks.py @@ -1,21 +1,31 @@ +""" +Invoke management tasks for the project. + +The current implementation with type annotations is not compatible +with invoke 1.6.0 and requires manual patching. + +See https://github.com/pyinvoke/invoke/pull/458/files +""" + + import time from pathlib import Path import requests -from invoke import task +from invoke import Context, task BASE_DIR = Path(__file__).parent.resolve(strict=True) SRC_DIR = BASE_DIR / "src" @task -def test(ctx): +def test(ctx: Context) -> None: with ctx.cd(SRC_DIR): ctx.run("pytest", pty=True, echo=True) @task -def test_cov(ctx): +def test_cov(ctx: Context) -> None: with ctx.cd(SRC_DIR): ctx.run( "pytest --cov=. --cov-report term-missing:skip-covered", @@ -25,41 +35,41 @@ def test_cov(ctx): @task -def pre_commit(ctx): +def pre_commit(ctx: Context) -> None: with ctx.cd(BASE_DIR): ctx.run("pre-commit run --all-files", pty=True) @task -def mypy(ctx): +def mypy(ctx: Context) -> None: with ctx.cd(BASE_DIR): ctx.run("pre-commit run --all-files mypy", pty=True) @task(pre=[pre_commit, test_cov]) -def check(ctx): +def check(ctx: Context) -> None: pass @task -def build(ctx): +def build(ctx: Context) -> None: with ctx.cd(BASE_DIR): ctx.run("docker-compose build django", pty=True, echo=True) @task -def publish(ctx): +def publish(ctx: Context) -> None: with ctx.cd(BASE_DIR): ctx.run("docker-compose push django", pty=True, echo=True) @task -def deploy(ctx): +def deploy(ctx: Context) -> None: ctx.run("ssh ubuntu /home/gaugendre/blog/update", pty=True, echo=True) @task -def check_alive(ctx): +def check_alive(ctx: Context) -> None: for _ in range(5): try: res = requests.get("https://gabnotes.org") @@ -70,12 +80,12 @@ def check_alive(ctx): @task(pre=[check, build, publish, deploy], post=[check_alive]) -def beam(ctx): +def beam(ctx: Context) -> None: pass @task -def download_db(ctx): +def download_db(ctx: Context) -> None: with ctx.cd(BASE_DIR): ctx.run("scp ubuntu:/home/gaugendre/blog/db/db.sqlite3 ./db/db.sqlite3") ctx.run("rm -rf src/media/")