Browse Source

Fix moar mypy

pull/2/head
Gabriel Augendre 6 months ago
parent
commit
880985dfe0
  1. 1
      .pre-commit-config.yaml
  2. 63
      poetry.lock
  3. 2
      pre-commit.Dockerfile
  4. 14
      pyproject.toml
  5. 24
      src/articles/admin.py
  6. 8
      src/articles/markdown.py
  7. 7
      src/articles/migrations/0024_auto_20201224_1746.py
  8. 8
      src/articles/migrations/0027_auto_20210303_1633.py
  9. 7
      src/articles/migrations/0030_tag_slug.py
  10. 4
      src/articles/models.py
  11. 2
      src/articles/tests/conftest.py
  12. 2
      src/articles/tests/test_admin.py
  13. 19
      src/articles/tests/test_api_views.py
  14. 4
      src/articles/tests/test_feed_views.py
  15. 34
      src/articles/tests/test_html_views.py
  16. 2
      src/articles/tests/test_migrations.py
  17. 14
      src/articles/tests/test_models.py
  18. 3
      src/articles/utils.py
  19. 4
      src/articles/views/api.py
  20. 5
      src/articles/views/feeds.py
  21. 28
      src/articles/views/html.py
  22. 12
      src/attachments/admin.py
  23. 4
      src/attachments/management/commands/reprocess_all_attachments.py
  24. 14
      src/attachments/models.py
  25. 10
      src/attachments/tests/conftest.py
  26. 2
      src/attachments/tests/test_models.py
  27. 5
      src/blog/tests/test_robots.py
  28. 2
      src/manage.py
  29. 2
      stubs/readtime/result.pyi
  30. 34
      tasks.py

1
.pre-commit-config.yaml

@ -78,4 +78,3 @@ repos:
entry: mypy
language: system
types_or: [python, pyi]
exclude: tasks.py$

63
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"},

2
pre-commit.Dockerfile

@ -1,2 +0,0 @@
FROM python:3.8.6-buster
RUN python3 -m pip install pre-commit==2.9.3

14
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

24
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)

8
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
)

7
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

8
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()

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

4
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)

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

2
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)

19
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={

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

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

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

14
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;

3
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:

4
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)

5
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:

28
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)

12
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} <a class="copy-button" data-to-copy="{1}" href="#">&#128203;</a>',
@ -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} <a class="copy-button" data-to-copy="{1}" href="#">&#128203;</a>',
@ -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

4
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()

14
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:

10
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,
}

2
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"

5
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"

2
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:

2
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

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

Loading…
Cancel
Save