Fix moar mypy

This commit is contained in:
Gabriel Augendre 2021-12-31 12:08:03 +01:00
parent 643dc7f4a1
commit 880985dfe0
30 changed files with 234 additions and 106 deletions

View file

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

63
poetry.lock generated
View file

@ -253,6 +253,35 @@ Django = ">=2.2"
phonenumbers = ["phonenumbers (>=7.0.2)"] phonenumbers = ["phonenumbers (>=7.0.2)"]
phonenumberslite = ["phonenumberslite (>=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]] [[package]]
name = "django-two-factor-auth" name = "django-two-factor-auth"
version = "1.13" version = "1.13"
@ -807,6 +836,22 @@ category = "dev"
optional = false optional = false
python-versions = "*" 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]] [[package]]
name = "types-requests" name = "types-requests"
version = "2.26.3" version = "2.26.3"
@ -930,7 +975,7 @@ multidict = ">=4.0"
[metadata] [metadata]
lock-version = "1.1" lock-version = "1.1"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "17e06d5348b12d6c12a95b1a9ba60278cddae6d15f80d4eba7173a67e5b5b123" content-hash = "35d359b39bfd7c907a1119797a4badf33b3f3c964a5ec90e85c781cf99fecf7e"
[metadata.files] [metadata.files]
asgiref = [ 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.tar.gz", hash = "sha256:52b2e5970133ec5ab701218b802f7ab237229854dc95fd239b7e9e77dc43731d"},
{file = "django_phonenumber_field-5.2.0-py3-none-any.whl", hash = "sha256:5547fb2b2cc690a306ba77a5038419afc8fa8298a486fb7895008e9067cc7e75"}, {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 = [] django-two-factor-auth = []
filelock = [ filelock = [
{file = "filelock-3.4.2-py3-none-any.whl", hash = "sha256:cf0fc6a2f8d26bd900f19bf33915ca70ba4dd8c56903eeb14e1e7a2fd7590146"}, {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.tar.gz", hash = "sha256:aa96a739184f48f69e6f30218400623fc5a95f5fec199c447663a32538440405"},
{file = "types_Pillow-8.3.11-py3-none-any.whl", hash = "sha256:998189334e616b1dd42c9634669efbf726184039e96e9a23ec95246e0ecff3fc"}, {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 = [ types-requests = [
{file = "types-requests-2.26.3.tar.gz", hash = "sha256:d63fa617846dcefff5aa2d59e47ab4ffd806e4bb0567115f7adbb5e438302fe4"}, {file = "types-requests-2.26.3.tar.gz", hash = "sha256:d63fa617846dcefff5aa2d59e47ab4ffd806e4bb0567115f7adbb5e438302fe4"},
{file = "types_requests-2.26.3-py3-none-any.whl", hash = "sha256:ad18284931c5ddbf050ccdd138f200d18fd56f88aa3567019d8da9b2d4fe0344"}, {file = "types_requests-2.26.3-py3-none-any.whl", hash = "sha256:ad18284931c5ddbf050ccdd138f200d18fd56f88aa3567019d8da9b2d4fe0344"},

View file

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

View file

@ -43,6 +43,7 @@ types-setuptools = "^57.4.5"
types-toml = "^0.10.1" types-toml = "^0.10.1"
types-beautifulsoup4 = "^4.10.7" types-beautifulsoup4 = "^4.10.7"
types-Pillow = "^8.3.11" types-Pillow = "^8.3.11"
django-stubs = "^1.9.0"
[tool.black] [tool.black]
target-version = ['py38'] target-version = ['py38']
@ -63,6 +64,16 @@ env = [
[tool.mypy] [tool.mypy]
mypy_path = "$MYPY_CONFIG_FILE_DIR/stubs:$MYPY_CONFIG_FILE_DIR/src" 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]] [[tool.mypy.overrides]]
module = [ module = [
@ -71,7 +82,8 @@ module = [
"django_otp.plugins.otp_static.models", "django_otp.plugins.otp_static.models",
"two_factor.models", "two_factor.models",
"django_otp.plugins.otp_totp.models", "django_otp.plugins.otp_totp.models",
"model_bakery" "model_bakery",
"invoke",
] ]
ignore_missing_imports = true ignore_missing_imports = true

View file

@ -1,6 +1,10 @@
from typing import cast
from django.contrib import admin, messages from django.contrib import admin, messages
from django.contrib.admin import register from django.contrib.admin import register
from django.contrib.auth.admin import UserAdmin 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 django.shortcuts import redirect
from .models import Article, Tag, User from .models import Article, Tag, User
@ -73,13 +77,13 @@ class ArticleAdmin(admin.ModelAdmin):
autocomplete_fields = ["tags"] autocomplete_fields = ["tags"]
show_full_result_count = False show_full_result_count = False
def get_queryset(self, request): def get_queryset(self, request: HttpRequest) -> QuerySet:
queryset = super().get_queryset(request) queryset = super().get_queryset(request)
queryset = queryset.prefetch_related("tags") queryset = queryset.prefetch_related("tags")
return queryset return queryset
@admin.action(description="Publish selected articles") @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"): if not request.user.has_perm("articles.change_article"):
messages.warning(request, "You're not allowed to do this.") messages.warning(request, "You're not allowed to do this.")
return return
@ -88,7 +92,7 @@ class ArticleAdmin(admin.ModelAdmin):
messages.success(request, f"{len(queryset)} articles published.") messages.success(request, f"{len(queryset)} articles published.")
@admin.action(description="Unpublish selected articles") @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"): if not request.user.has_perm("articles.change_article"):
messages.warning(request, "You're not allowed to do this.") messages.warning(request, "You're not allowed to do this.")
return return
@ -97,7 +101,7 @@ class ArticleAdmin(admin.ModelAdmin):
messages.success(request, f"{len(queryset)} articles unpublished.") messages.success(request, f"{len(queryset)} articles unpublished.")
@admin.action(description="Refresh draft key of selected articles") @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"): if not request.user.has_perm("articles.change_article"):
messages.warning(request, "You're not allowed to do this.") messages.warning(request, "You're not allowed to do this.")
return return
@ -110,12 +114,14 @@ class ArticleAdmin(admin.ModelAdmin):
class Media: class Media:
css = {"all": ("admin_articles.css",)} 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: 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) 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: if "_preview" in request.POST:
obj.save() obj.save()
return redirect("article-detail", slug=obj.slug) return redirect("article-detail", slug=obj.slug)
@ -129,11 +135,11 @@ class ArticleAdmin(admin.ModelAdmin):
return redirect(".") return redirect(".")
return super().response_change(request, obj) 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" return f"{instance.get_read_time()} min"
@admin.display(boolean=True) @admin.display(boolean=True)
def has_custom_css(self, instance: Article): def has_custom_css(self, instance: Article) -> bool:
return bool(instance.custom_css) return bool(instance.custom_css)

View file

@ -9,15 +9,15 @@ from markdown.inlinepatterns import (
class LazyImageInlineProcessor(ImageInlineProcessor): class LazyImageInlineProcessor(ImageInlineProcessor):
def handleMatch(self, m, data): def handleMatch(self, m, data): # type: ignore
el, match_start, index = super().handleMatch(m, data) el, match_start, index = super().handleMatch(m, data)
if el is not None: if el is not None:
el.set("loading", "lazy") el.set("loading", "lazy")
return el, match_start, index return el, match_start, index # type: ignore
class LazyImageReferenceInlineProcessor(ImageReferenceInlineProcessor): class LazyImageReferenceInlineProcessor(ImageReferenceInlineProcessor):
def makeTag(self, href, title, text): def makeTag(self, href, title, text): # type: ignore
el = super().makeTag(href, title, text) el = super().makeTag(href, title, text)
if el is not None: if el is not None:
el.set("loading", "lazy") el.set("loading", "lazy")
@ -25,7 +25,7 @@ class LazyImageReferenceInlineProcessor(ImageReferenceInlineProcessor):
class LazyLoadingImageExtension(Extension): class LazyLoadingImageExtension(Extension):
def extendMarkdown(self, md: Markdown): def extendMarkdown(self, md: Markdown) -> None:
md.inlinePatterns.register( md.inlinePatterns.register(
LazyImageInlineProcessor(IMAGE_LINK_RE, md), "image_link", 150 LazyImageInlineProcessor(IMAGE_LINK_RE, md), "image_link", 150
) )

View file

@ -1,15 +1,16 @@
# Generated by Django 3.1.3 on 2020-12-24 16:46 # 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 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") Article = apps.get_model("articles", "Article")
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
Article.objects.using(db_alias).filter(slug="about-me").update(is_home=True) 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 pass

View file

@ -1,15 +1,17 @@
# Generated by Django 3.1.5 on 2021-03-03 15:33 # 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 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") Tag = apps.get_model("articles", "Tag")
Article = apps.get_model("articles", "Article") Article = apps.get_model("articles", "Article")
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
articles = Article.objects.using(db_alias).all() articles = Article.objects.using(db_alias).all()
for article in articles: for article in articles:
tags = [] tags = []
keyword: str
for keyword in list( for keyword in list(
filter(None, map(lambda k: k.strip(), article.keywords.split(","))) 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"]) 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") Article = apps.get_model("articles", "Article")
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
articles = Article.objects.using(db_alias).all() articles = Article.objects.using(db_alias).all()

View file

@ -1,10 +1,11 @@
# Generated by Django 3.1.5 on 2021-03-04 17:17 # 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 import migrations, models
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.utils.text import slugify 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") Tag = apps.get_model("articles", "Tag")
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
tags = Tag.objects.using(db_alias).all() tags = Tag.objects.using(db_alias).all()
@ -13,7 +14,7 @@ def forwards(apps, schema_editor):
Tag.objects.bulk_update(tags, ["slug"]) 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") Tag = apps.get_model("articles", "Tag")
db_alias = schema_editor.connection.alias db_alias = schema_editor.connection.alias
Tag.objects.using(db_alias).update(slug="") Tag.objects.using(db_alias).update(slug="")

View file

@ -3,7 +3,7 @@ from __future__ import annotations
import random import random
import uuid import uuid
from functools import cached_property from functools import cached_property
from typing import Sequence from typing import Any, Sequence
import rcssmin import rcssmin
import readtime import readtime
@ -115,7 +115,7 @@ class Article(models.Model):
self.save() self.save()
return self return self
def save(self, *args, **kwargs) -> None: def save(self, *args: Any, **kwargs: Any) -> None:
if not self.slug: if not self.slug:
self.slug = slugify(self.title) self.slug = slugify(self.title)
return super().save(*args, **kwargs) return super().save(*args, **kwargs)

View file

@ -55,5 +55,5 @@ def unpublished_article(author: User) -> Article:
@pytest.fixture(autouse=True, scope="session") @pytest.fixture(autouse=True, scope="session")
def _collect_static(): def _collect_static() -> None:
call_command("collectstatic", "--no-input", "--clear") call_command("collectstatic", "--no-input", "--clear")

View file

@ -8,7 +8,7 @@ from articles.models import User
@pytest.mark.django_db() @pytest.mark.django_db()
# @pytest.mark.skip("Fails for no apparent reason") # @pytest.mark.skip("Fails for no apparent reason")
@pytest.mark.flaky(reruns=5, reruns_delay=3) @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) client.force_login(author)
url = reverse("admin:articles_article_add") url = reverse("admin:articles_article_add")
res = client.get(url) res = client.get(url)

View file

@ -1,4 +1,5 @@
import pytest import pytest
from django.http import HttpResponse
from django.test import Client from django.test import Client
from django.urls import reverse from django.urls import reverse
@ -7,7 +8,9 @@ from articles.utils import format_article_content
@pytest.mark.django_db() @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( api_res = client.post(
reverse("api-render-article", kwargs={"article_pk": published_article.pk}), reverse("api-render-article", kwargs={"article_pk": published_article.pk}),
data={"content": published_article.content}, data={"content": published_article.content},
@ -16,7 +19,9 @@ def test_unauthenticated_render_redirects(published_article: Article, client: Cl
@pytest.mark.django_db() @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) client.force_login(published_article.author)
api_res = post_article(client, published_article, published_article.content) api_res = post_article(client, published_article, published_article.content)
standard_res = client.get( standard_res = client.get(
@ -36,7 +41,9 @@ def test_render_article_same_content(published_article: Article, client: Client)
@pytest.mark.django_db() @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) client.force_login(published_article.author)
preview_content = "This is a different content **with strong emphasis**" preview_content = "This is a different content **with strong emphasis**"
api_res = post_article(client, published_article, preview_content) 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() @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) client.force_login(published_article.author)
original_content = published_article.content original_content = published_article.content
preview_content = "This is a different content **with strong emphasis**" 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() @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) client.force_login(published_article.author)
api_res = client.post( api_res = client.post(
reverse("api-render-article", kwargs={"article_pk": published_article.pk}), 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 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( return client.post(
reverse("api-render-article", kwargs={"article_pk": article.pk}), reverse("api-render-article", kwargs={"article_pk": article.pk}),
data={ data={

View file

@ -8,7 +8,7 @@ from articles.views.feeds import CompleteFeed
@pytest.mark.django_db() @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")) res = client.get(reverse("complete-feed"))
assert res.status_code == 200 assert res.status_code == 200
assert "application/rss+xml" in res["content-type"] 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() @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) baker.make(Article, 100, status=Article.PUBLISHED, author=author)
res = client.get(reverse("complete-feed")) res = client.get(reverse("complete-feed"))
content = res.content.decode("utf-8") content = res.content.decode("utf-8")

View file

@ -1,13 +1,15 @@
import pytest import pytest
from django.http import HttpResponse
from django.test import Client from django.test import Client
from django.urls import reverse from django.urls import reverse
from model_bakery import baker from model_bakery import baker
from pytest_django.fixtures import SettingsWrapper
from articles.models import Article, User from articles.models import Article, User
@pytest.mark.django_db() @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")) res = client.get(reverse("articles-list"))
assert res.status_code == 200 assert res.status_code == 200
content = res.content.decode("utf-8") content = res.content.decode("utf-8")
@ -16,7 +18,7 @@ def test_can_access_list(client: Client, published_article: Article):
@pytest.mark.django_db() @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" title = "This is a very long title mouahahaha"
abstract = "Some abstract" abstract = "Some abstract"
after = "Some content after 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() @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) _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})) res = client.get(reverse("article-detail", kwargs={"slug": item.slug}))
_assert_article_is_rendered(item, res) _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 assert res.status_code == 200
content = res.content.decode("utf-8") content = res.content.decode("utf-8")
assert item.title in content assert item.title in content
@ -55,7 +57,7 @@ def _assert_article_is_rendered(item: Article, res):
@pytest.mark.django_db() @pytest.mark.django_db()
def test_anonymous_cant_access_draft_detail( def test_anonymous_cant_access_draft_detail(
client: Client, unpublished_article: Article client: Client, unpublished_article: Article
): ) -> None:
res = client.get( res = client.get(
reverse("article-detail", kwargs={"slug": unpublished_article.slug}) reverse("article-detail", kwargs={"slug": unpublished_article.slug})
) )
@ -65,7 +67,7 @@ def test_anonymous_cant_access_draft_detail(
@pytest.mark.django_db() @pytest.mark.django_db()
def test_anonymous_can_access_draft_detail_with_key( def test_anonymous_can_access_draft_detail_with_key(
client: Client, unpublished_article: Article client: Client, unpublished_article: Article
): ) -> None:
res = client.get( res = client.get(
reverse("article-detail", kwargs={"slug": unpublished_article.slug}) reverse("article-detail", kwargs={"slug": unpublished_article.slug})
+ f"?draft_key={unpublished_article.draft_key}" + f"?draft_key={unpublished_article.draft_key}"
@ -76,7 +78,7 @@ def test_anonymous_can_access_draft_detail_with_key(
@pytest.mark.django_db() @pytest.mark.django_db()
def test_user_can_access_draft_detail( def test_user_can_access_draft_detail(
client: Client, author: User, unpublished_article: Article client: Client, author: User, unpublished_article: Article
): ) -> None:
client.force_login(author) client.force_login(author)
_test_access_article_by_slug(client, unpublished_article) _test_access_article_by_slug(client, unpublished_article)
@ -84,7 +86,7 @@ def test_user_can_access_draft_detail(
@pytest.mark.django_db() @pytest.mark.django_db()
def test_anonymous_cant_access_drafts_list( def test_anonymous_cant_access_drafts_list(
client: Client, unpublished_article: Article client: Client, unpublished_article: Article
): ) -> None:
res = client.get(reverse("drafts-list")) res = client.get(reverse("drafts-list"))
assert res.status_code == 302 assert res.status_code == 302
@ -92,7 +94,7 @@ def test_anonymous_cant_access_drafts_list(
@pytest.mark.django_db() @pytest.mark.django_db()
def test_user_can_access_drafts_list( def test_user_can_access_drafts_list(
client: Client, author: User, unpublished_article: Article client: Client, author: User, unpublished_article: Article
): ) -> None:
client.force_login(author) client.force_login(author)
res = client.get(reverse("drafts-list")) res = client.get(reverse("drafts-list"))
assert res.status_code == 200 assert res.status_code == 200
@ -101,7 +103,7 @@ def test_user_can_access_drafts_list(
@pytest.mark.django_db() @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" settings.GOATCOUNTER_DOMAIN = "gc.gabnotes.org"
res = client.get(reverse("articles-list")) res = client.get(reverse("articles-list"))
content = res.content.decode("utf-8") content = res.content.decode("utf-8")
@ -110,7 +112,9 @@ def test_has_goatcounter_if_set(client: Client, settings):
@pytest.mark.django_db() @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 settings.GOATCOUNTER_DOMAIN = None
res = client.get(reverse("articles-list")) res = client.get(reverse("articles-list"))
content = res.content.decode("utf-8") content = res.content.decode("utf-8")
@ -119,7 +123,9 @@ def test_doesnt_have_goatcounter_if_unset(client: Client, settings):
@pytest.mark.django_db() @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) client.force_login(author)
settings.GOATCOUNTER_DOMAIN = "gc.gabnotes.org" settings.GOATCOUNTER_DOMAIN = "gc.gabnotes.org"
res = client.get(reverse("articles-list")) 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() @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})) res = client.get(reverse("article-detail", kwargs={"slug": published_article.slug}))
assert res.status_code == 200 assert res.status_code == 200
content = res.content.decode("utf-8") content = res.content.decode("utf-8")

View file

@ -3,5 +3,5 @@ from django.core.management import call_command
@pytest.mark.django_db() @pytest.mark.django_db()
def test_missing_migrations(): def test_missing_migrations() -> None:
call_command("makemigrations", "--check") call_command("makemigrations", "--check")

View file

@ -4,7 +4,7 @@ from articles.models import Article, User
@pytest.mark.django_db() @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.status == Article.DRAFT
assert unpublished_article.published_at is None assert unpublished_article.published_at is None
published_article = unpublished_article.publish() published_article = unpublished_article.publish()
@ -13,7 +13,7 @@ def test_publish_article(unpublished_article: Article):
@pytest.mark.django_db() @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.status == Article.PUBLISHED
assert published_article.published_at is not None assert published_article.published_at is not None
unpublished_article = published_article.unpublish() unpublished_article = published_article.unpublish()
@ -22,7 +22,7 @@ def test_unpublish_article(published_article: Article):
@pytest.mark.django_db() @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(). # Explicitly calling bulk_create with one article because it doesn't call save().
articles = Article.objects.bulk_create( articles = Article.objects.bulk_create(
[Article(author=author, title="noice title", slug="", status=Article.DRAFT)] [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() @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 original_slug = published_article.slug
published_article.title = "This is a brand new title" published_article.title = "This is a brand new title"
published_article.save() published_article.save()
@ -42,19 +42,19 @@ def test_save_article_doesnt_change_existing_slug(published_article: Article):
@pytest.mark.django_db() @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 = "" published_article.custom_css = ""
assert published_article.get_minified_custom_css == "" assert published_article.get_minified_custom_css == ""
@pytest.mark.django_db() @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}" published_article.custom_css = ".cls {\n background-color: red;\n}"
assert published_article.get_minified_custom_css == ".cls{background-color:red}" assert published_article.get_minified_custom_css == ".cls{background-color:red}"
@pytest.mark.django_db() @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 = """\ published_article.custom_css = """\
.profile { .profile {
display: flex; display: flex;

View file

@ -3,13 +3,14 @@ import re
import markdown import markdown
from bs4 import BeautifulSoup from bs4 import BeautifulSoup
from django.conf import settings from django.conf import settings
from django.http import HttpRequest
from markdown.extensions.codehilite import CodeHiliteExtension from markdown.extensions.codehilite import CodeHiliteExtension
from markdown.extensions.toc import TocExtension from markdown.extensions.toc import TocExtension
from articles.markdown import LazyLoadingImageExtension 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: if request:
return request.build_absolute_uri(url) return request.build_absolute_uri(url)
else: else:

View file

@ -1,7 +1,7 @@
from typing import Any from typing import Any
from django.contrib.auth.decorators import login_required 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.shortcuts import render
from django.views.decorators.http import require_POST from django.views.decorators.http import require_POST
@ -10,7 +10,7 @@ from articles.models import Article, Tag
@login_required @login_required
@require_POST @require_POST
def render_article(request, article_pk: int) -> HttpResponse: def render_article(request: HttpRequest, article_pk: int) -> HttpResponse:
template = "articles/article_detail.html" template = "articles/article_detail.html"
article = Article.objects.get(pk=article_pk) article = Article.objects.get(pk=article_pk)
article.content = request.POST.get("content", article.content) article.content = request.POST.get("content", article.content)

View file

@ -1,8 +1,9 @@
from datetime import datetime from datetime import datetime
from typing import Iterable from typing import Any, Iterable
from django.contrib.syndication.views import Feed from django.contrib.syndication.views import Feed
from django.db.models import QuerySet from django.db.models import QuerySet
from django.http import HttpRequest
from articles.models import Article, Tag from articles.models import Article, Tag
from blog import settings from blog import settings
@ -33,7 +34,7 @@ class CompleteFeed(BaseFeed):
class TagFeed(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")) return Tag.objects.get(slug=kwargs.get("slug"))
def title(self, tag: Tag) -> str: def title(self, tag: Tag) -> str:

View file

@ -5,7 +5,9 @@ from typing import Any
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.core.paginator import Page 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.shortcuts import get_object_or_404
from django.views import generic from django.views import generic
from django.views.generic import DetailView from django.views.generic import DetailView
@ -20,7 +22,7 @@ class BaseArticleListView(generic.ListView):
main_title = "Blog posts" main_title = "Blog posts"
html_title = "" 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 = super().get_context_data(**kwargs)
context["blog_title"] = settings.BLOG["title"] context["blog_title"] = settings.BLOG["title"]
context["blog_description"] = settings.BLOG["description"] context["blog_description"] = settings.BLOG["description"]
@ -51,7 +53,7 @@ class PublicArticleListView(BaseArticleListView):
class ArticlesListView(PublicArticleListView): 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) context = super().get_context_data(**kwargs)
home_article = Article.objects.filter( home_article = Article.objects.filter(
status=Article.PUBLISHED, is_home=True status=Article.PUBLISHED, is_home=True
@ -64,12 +66,12 @@ class SearchArticlesListView(PublicArticleListView):
template_name = "articles/article_search.html" template_name = "articles/article_search.html"
html_title = "Search" 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 = super().get_context_data(**kwargs)
context["search_expression"] = self.request.GET.get("s") or "" context["search_expression"] = self.request.GET.get("s") or ""
return context return context
def get_queryset(self): def get_queryset(self) -> QuerySet:
queryset = super().get_queryset() queryset = super().get_queryset()
search_expression = self.request.GET.get("s") search_expression = self.request.GET.get("s")
if not search_expression: if not search_expression:
@ -98,25 +100,27 @@ class TagArticlesListView(PublicArticleListView):
main_title = "" main_title = ""
html_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.tag = get_object_or_404(Tag, slug=self.kwargs.get("slug"))
self.main_title = self.html_title = f"{self.tag.name} articles" self.main_title = self.html_title = f"{self.tag.name} articles"
return super().dispatch(request, *args, **kwargs) 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 = super().get_context_data(**kwargs)
context["feed_title"] = self.tag.get_feed_title() context["feed_title"] = self.tag.get_feed_title()
context["feed_url"] = self.tag.get_feed_url() context["feed_url"] = self.tag.get_feed_url()
return context return context
def get_queryset(self): def get_queryset(self) -> QuerySet:
return super().get_queryset().filter(tags=self.tag) return super().get_queryset().filter(tags=self.tag)
class DraftsListView(LoginRequiredMixin, BaseArticleListView): class DraftsListView(LoginRequiredMixin, BaseArticleListView):
queryset = Article.objects.filter(status=Article.DRAFT) 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 = super().get_context_data(**kwargs)
context["title"] = "Drafts" context["title"] = "Drafts"
context["title_header"] = context["title"] context["title_header"] = context["title"]
@ -128,7 +132,7 @@ class ArticleDetailView(DetailView):
context_object_name = "article" context_object_name = "article"
template_name = "articles/article_detail.html" template_name = "articles/article_detail.html"
def get_queryset(self): def get_queryset(self) -> QuerySet:
key = self.request.GET.get("draft_key") key = self.request.GET.get("draft_key")
if key: if key:
return Article.objects.filter(draft_key=key).prefetch_related("tags") return Article.objects.filter(draft_key=key).prefetch_related("tags")
@ -138,7 +142,7 @@ class ArticleDetailView(DetailView):
queryset = queryset.filter(status=Article.PUBLISHED) queryset = queryset.filter(status=Article.PUBLISHED)
return queryset return queryset
def get_object(self, queryset=None) -> Article: def get_object(self, queryset: QuerySet | None = None) -> Article:
obj: Article = super().get_object(queryset) obj: Article = super().get_object(queryset)
if not self.request.user.is_authenticated: if not self.request.user.is_authenticated:
obj.views_count = F("views_count") + 1 obj.views_count = F("views_count") + 1
@ -146,6 +150,6 @@ class ArticleDetailView(DetailView):
return obj return obj
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
kwargs["tags"] = self.object.tags.all() kwargs["tags"] = self.object.tags.all()
return super().get_context_data(**kwargs) return super().get_context_data(**kwargs)

View file

@ -1,5 +1,7 @@
from django.contrib import admin, messages from django.contrib import admin, messages
from django.contrib.admin import register 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 django.utils.html import format_html
from attachments.models import Attachment from attachments.models import Attachment
@ -38,7 +40,7 @@ class AttachmentAdmin(admin.ModelAdmin):
class Media: class Media:
js = ["attachments/js/copy_url.js"] js = ["attachments/js/copy_url.js"]
def processed_file_url(self, instance): def processed_file_url(self, instance: Attachment) -> str:
if instance.processed_file: if instance.processed_file:
return format_html( return format_html(
'{0} <a class="copy-button" data-to-copy="{1}" href="#">&#128203;</a>', '{0} <a class="copy-button" data-to-copy="{1}" href="#">&#128203;</a>',
@ -47,7 +49,7 @@ class AttachmentAdmin(admin.ModelAdmin):
) )
return "" return ""
def original_file_url(self, instance): def original_file_url(self, instance: Attachment) -> str:
if instance.original_file: if instance.original_file:
return format_html( return format_html(
'{0} <a class="copy-button" data-to-copy="{1}" href="#">&#128203;</a>', '{0} <a class="copy-button" data-to-copy="{1}" href="#">&#128203;</a>',
@ -57,7 +59,7 @@ class AttachmentAdmin(admin.ModelAdmin):
return "" return ""
@admin.action(description="Set as open graph image") @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: if len(queryset) != 1:
messages.error(request, "You must select only one attachment") messages.error(request, "You must select only one attachment")
return return
@ -66,7 +68,9 @@ class AttachmentAdmin(admin.ModelAdmin):
messages.success(request, "Done") messages.success(request, "Done")
@admin.action(description="Reprocess selected attachments") @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: if len(queryset) == 0:
messages.error(request, "You must select at least one attachment") messages.error(request, "You must select at least one attachment")
return return

View file

@ -1,3 +1,5 @@
from typing import Any
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from attachments.models import Attachment from attachments.models import Attachment
@ -6,7 +8,7 @@ from attachments.models import Attachment
class Command(BaseCommand): class Command(BaseCommand):
help = "Reprocess all attachments" help = "Reprocess all attachments"
def handle(self, *args, **options): def handle(self, *args: Any, **options: Any) -> None:
for attachment in Attachment.objects.all(): for attachment in Attachment.objects.all():
self.stdout.write(f"Processing {attachment}...") self.stdout.write(f"Processing {attachment}...")
attachment.reprocess() attachment.reprocess()

View file

@ -1,19 +1,23 @@
from __future__ import annotations
import json import json
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from typing import Any
import requests import requests
from django.conf import settings from django.conf import settings
from django.core.files import File from django.core.files import File
from django.db import models from django.db import models
from django.db.models.fields.files import FieldFile from django.db.models.fields.files import FieldFile
from django.http import HttpRequest
from PIL import Image from PIL import Image
from articles.utils import build_full_absolute_url from articles.utils import build_full_absolute_url
class AbsoluteUrlFieldFile(FieldFile): 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) return build_full_absolute_url(request, self.url)
@ -22,7 +26,7 @@ class AbsoluteUrlFileField(models.FileField):
class AttachmentManager(models.Manager): 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() return self.filter(open_graph_image=True).first()
@ -37,14 +41,14 @@ class Attachment(models.Model):
class Meta: class Meta:
ordering = ["description"] ordering = ["description"]
def __str__(self): def __str__(self) -> str:
return f"{self.description} ({self.original_file.name})" return f"{self.description} ({self.original_file.name})"
def reprocess(self): def reprocess(self) -> None:
self.processed_file = None self.processed_file = None
self.save() self.save()
def save(self, *args, **kwargs): def save(self, *args: Any, **kwargs: Any) -> None:
super().save(*args, **kwargs) super().save(*args, **kwargs)
if self.processed_file: if self.processed_file:

View file

@ -1,13 +1,19 @@
from typing import Any, Protocol
import pytest import pytest
def replace_post_body(request): class Request(Protocol):
body: Any
def replace_post_body(request: Request) -> Request:
request.body = None request.body = None
return request return request
@pytest.fixture(scope="module") @pytest.fixture(scope="module")
def vcr_config(): def vcr_config() -> dict[str, Any]:
return { return {
"before_record_request": replace_post_body, "before_record_request": replace_post_body,
} }

View file

@ -9,7 +9,7 @@ from attachments.models import Attachment
@pytest.mark.block_network() @pytest.mark.block_network()
@pytest.mark.vcr() @pytest.mark.vcr()
@pytest.mark.django_db() @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 # This path manipulation is required to make the test run from this directory
# or from upper in the hierarchy (e.g.: settings.BASE_DIR) # or from upper in the hierarchy (e.g.: settings.BASE_DIR)
img_path = Path(__file__).parent / "resources" / "image.png" img_path = Path(__file__).parent / "resources" / "image.png"

View file

@ -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") res = client.get("/robots.txt")
assert res.status_code == 200 assert res.status_code == 200
assert res["Content-Type"] == "text/plain" assert res["Content-Type"] == "text/plain"

View file

@ -4,7 +4,7 @@ import os
import sys import sys
def main(): def main() -> None:
"""Run administrative tasks.""" # noqa: DAR401 """Run administrative tasks.""" # noqa: DAR401
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "blog.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "blog.settings")
try: try:

View file

@ -4,7 +4,7 @@ class Result:
delta: timedelta | None delta: timedelta | None
wpm: int | None wpm: int | None
def __init__(self, seconds: int | None = ..., wpm: int | None = ...) -> None: ... def __init__(self, seconds: int | None = ..., wpm: int | None = ...) -> None: ...
def __unicode__(self): ... def __unicode__(self) -> str: ...
@property @property
def seconds(self) -> int: ... def seconds(self) -> int: ...
@property @property

View file

@ -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 import time
from pathlib import Path from pathlib import Path
import requests import requests
from invoke import task from invoke import Context, task
BASE_DIR = Path(__file__).parent.resolve(strict=True) BASE_DIR = Path(__file__).parent.resolve(strict=True)
SRC_DIR = BASE_DIR / "src" SRC_DIR = BASE_DIR / "src"
@task @task
def test(ctx): def test(ctx: Context) -> None:
with ctx.cd(SRC_DIR): with ctx.cd(SRC_DIR):
ctx.run("pytest", pty=True, echo=True) ctx.run("pytest", pty=True, echo=True)
@task @task
def test_cov(ctx): def test_cov(ctx: Context) -> None:
with ctx.cd(SRC_DIR): with ctx.cd(SRC_DIR):
ctx.run( ctx.run(
"pytest --cov=. --cov-report term-missing:skip-covered", "pytest --cov=. --cov-report term-missing:skip-covered",
@ -25,41 +35,41 @@ def test_cov(ctx):
@task @task
def pre_commit(ctx): def pre_commit(ctx: Context) -> None:
with ctx.cd(BASE_DIR): with ctx.cd(BASE_DIR):
ctx.run("pre-commit run --all-files", pty=True) ctx.run("pre-commit run --all-files", pty=True)
@task @task
def mypy(ctx): def mypy(ctx: Context) -> None:
with ctx.cd(BASE_DIR): with ctx.cd(BASE_DIR):
ctx.run("pre-commit run --all-files mypy", pty=True) ctx.run("pre-commit run --all-files mypy", pty=True)
@task(pre=[pre_commit, test_cov]) @task(pre=[pre_commit, test_cov])
def check(ctx): def check(ctx: Context) -> None:
pass pass
@task @task
def build(ctx): def build(ctx: Context) -> None:
with ctx.cd(BASE_DIR): with ctx.cd(BASE_DIR):
ctx.run("docker-compose build django", pty=True, echo=True) ctx.run("docker-compose build django", pty=True, echo=True)
@task @task
def publish(ctx): def publish(ctx: Context) -> None:
with ctx.cd(BASE_DIR): with ctx.cd(BASE_DIR):
ctx.run("docker-compose push django", pty=True, echo=True) ctx.run("docker-compose push django", pty=True, echo=True)
@task @task
def deploy(ctx): def deploy(ctx: Context) -> None:
ctx.run("ssh ubuntu /home/gaugendre/blog/update", pty=True, echo=True) ctx.run("ssh ubuntu /home/gaugendre/blog/update", pty=True, echo=True)
@task @task
def check_alive(ctx): def check_alive(ctx: Context) -> None:
for _ in range(5): for _ in range(5):
try: try:
res = requests.get("https://gabnotes.org") res = requests.get("https://gabnotes.org")
@ -70,12 +80,12 @@ def check_alive(ctx):
@task(pre=[check, build, publish, deploy], post=[check_alive]) @task(pre=[check, build, publish, deploy], post=[check_alive])
def beam(ctx): def beam(ctx: Context) -> None:
pass pass
@task @task
def download_db(ctx): def download_db(ctx: Context) -> None:
with ctx.cd(BASE_DIR): with ctx.cd(BASE_DIR):
ctx.run("scp ubuntu:/home/gaugendre/blog/db/db.sqlite3 ./db/db.sqlite3") ctx.run("scp ubuntu:/home/gaugendre/blog/db/db.sqlite3 ./db/db.sqlite3")
ctx.run("rm -rf src/media/") ctx.run("rm -rf src/media/")