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/")