Browse Source

Add mypy

pull/2/head
Gabriel Augendre 6 months ago
parent
commit
40a4a0a308
  1. 8
      .pre-commit-config.yaml
  2. 198
      poetry.lock
  3. 27
      pyproject.toml
  4. 11
      src/articles/admin.py
  5. 29
      src/articles/context_processors.py
  6. 45
      src/articles/models.py
  7. 10
      src/articles/tests/test_api_views.py
  8. 2
      src/articles/tests/test_html_views.py
  9. 22
      src/articles/utils.py
  10. 11
      src/articles/views/api.py
  11. 2
      src/articles/views/feeds.py
  12. 10
      src/articles/views/html.py
  13. 6
      src/attachments/admin.py
  14. 4
      src/blog/urls.py
  15. 3
      stubs/rcssmin.pyi
  16. 3
      stubs/readtime/__init__.pyi
  17. 5
      stubs/readtime/api.pyi
  18. 14
      stubs/readtime/result.pyi
  19. 9
      tasks.py

8
.pre-commit-config.yaml

@ -1,7 +1,7 @@
exclude: (\.min\.(js|css)(\.map)?$|/vendor/)
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.0.1
rev: v4.1.0
hooks:
- id: check-ast
- id: check-json
@ -29,7 +29,7 @@ repos:
hooks:
- id: black
- repo: https://github.com/asottile/pyupgrade
rev: v2.29.1
rev: v2.30.0
hooks:
- id: pyupgrade
args:
@ -40,7 +40,7 @@ repos:
- id: django-upgrade
args: [--target-version, "4.0"]
- repo: https://github.com/rtts/djhtml
rev: v1.4.10
rev: v1.4.11
hooks:
- id: djhtml
- repo: https://github.com/pycqa/flake8
@ -60,7 +60,7 @@ repos:
- id: prettier
types_or: [javascript, css]
- repo: https://github.com/pre-commit/mirrors-eslint
rev: v8.4.1
rev: v8.5.0
hooks:
- id: eslint
args: [--fix]

198
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"
@ -396,6 +425,31 @@ category = "dev"
optional = false
python-versions = ">=3.6"
[[package]]
name = "mypy"
version = "0.930"
description = "Optional static typing for Python"
category = "dev"
optional = false
python-versions = ">=3.6"
[package.dependencies]
mypy-extensions = ">=0.4.3"
tomli = ">=1.1.0"
typing-extensions = ">=3.10"
[package.extras]
dmypy = ["psutil (>=4.0)"]
python2 = ["typed-ast (>=1.4.0,<2)"]
[[package]]
name = "mypy-extensions"
version = "0.4.3"
description = "Experimental type system extensions for programs checked with the mypy typechecker."
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "nodeenv"
version = "1.6.0"
@ -758,6 +812,78 @@ category = "dev"
optional = false
python-versions = ">=3.7"
[[package]]
name = "types-beautifulsoup4"
version = "4.10.7"
description = "Typing stubs for beautifulsoup4"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "types-markdown"
version = "3.3.10"
description = "Typing stubs for Markdown"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "types-pillow"
version = "8.3.11"
description = "Typing stubs for Pillow"
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"
description = "Typing stubs for requests"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "types-setuptools"
version = "57.4.5"
description = "Typing stubs for setuptools"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "types-toml"
version = "0.10.1"
description = "Typing stubs for toml"
category = "dev"
optional = false
python-versions = "*"
[[package]]
name = "typing-extensions"
version = "4.0.1"
description = "Backported and Experimental Type Hints for Python 3.6+"
category = "dev"
optional = false
python-versions = ">=3.6"
[[package]]
name = "tzdata"
version = "2021.5"
@ -849,7 +975,7 @@ multidict = ">=4.0"
[metadata]
lock-version = "1.1"
python-versions = "^3.10"
content-hash = "177905663c5207bbaed9cc8a093d99b96e932297e9901d8f7b7985583583fe14"
content-hash = "35d359b39bfd7c907a1119797a4badf33b3f3c964a5ec90e85c781cf99fecf7e"
[metadata.files]
asgiref = [
@ -1015,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"},
@ -1189,6 +1323,32 @@ multidict = [
{file = "multidict-5.2.0-cp39-cp39-win_amd64.whl", hash = "sha256:c9631c642e08b9fff1c6255487e62971d8b8e821808ddd013d8ac058087591ac"},
{file = "multidict-5.2.0.tar.gz", hash = "sha256:0dd1c93edb444b33ba2274b66f63def8a327d607c6c790772f448a53b6ea59ce"},
]
mypy = [
{file = "mypy-0.930-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:221cc94dc6a801ccc2be7c0c9fd791c5e08d1fa2c5e1c12dec4eab15b2469871"},
{file = "mypy-0.930-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:db3a87376a1380f396d465bed462e76ea89f838f4c5e967d68ff6ee34b785c31"},
{file = "mypy-0.930-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1d2296f35aae9802eeb1327058b550371ee382d71374b3e7d2804035ef0b830b"},
{file = "mypy-0.930-cp310-cp310-win_amd64.whl", hash = "sha256:959319b9a3cafc33a8185f440a433ba520239c72e733bf91f9efd67b0a8e9b30"},
{file = "mypy-0.930-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:45a4dc21c789cfd09b8ccafe114d6de66f0b341ad761338de717192f19397a8c"},
{file = "mypy-0.930-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:1e689e92cdebd87607a041585f1dc7339aa2e8a9f9bad9ba7e6ece619431b20c"},
{file = "mypy-0.930-cp36-cp36m-win_amd64.whl", hash = "sha256:ed4e0ea066bb12f56b2812a15ff223c57c0a44eca817ceb96b214bb055c7051f"},
{file = "mypy-0.930-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:a9d8dffefba634b27d650e0de2564379a1a367e2e08d6617d8f89261a3bf63b2"},
{file = "mypy-0.930-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:b419e9721260161e70d054a15abbd50603c16f159860cfd0daeab647d828fc29"},
{file = "mypy-0.930-cp37-cp37m-win_amd64.whl", hash = "sha256:601f46593f627f8a9b944f74fd387c9b5f4266b39abad77471947069c2fc7651"},
{file = "mypy-0.930-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1ea7199780c1d7940b82dbc0a4e37722b4e3851264dbba81e01abecc9052d8a7"},
{file = "mypy-0.930-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:70b197dd8c78fc5d2daf84bd093e8466a2b2e007eedaa85e792e513a820adbf7"},
{file = "mypy-0.930-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:5feb56f8bb280468fe5fc8e6f56f48f99aa0df9eed3c507a11505ee4657b5380"},
{file = "mypy-0.930-cp38-cp38-win_amd64.whl", hash = "sha256:2e9c5409e9cb81049bb03fa1009b573dea87976713e3898561567a86c4eaee01"},
{file = "mypy-0.930-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:554873e45c1ca20f31ddf873deb67fa5d2e87b76b97db50669f0468ccded8fae"},
{file = "mypy-0.930-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:0feb82e9fa849affca7edd24713dbe809dce780ced9f3feca5ed3d80e40b777f"},
{file = "mypy-0.930-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:bc1a0607ea03c30225347334af66b0af12eefba018a89a88c209e02b7065ea95"},
{file = "mypy-0.930-cp39-cp39-win_amd64.whl", hash = "sha256:f9f665d69034b1fcfdbcd4197480d26298bbfb5d2dfe206245b6498addb34999"},
{file = "mypy-0.930-py3-none-any.whl", hash = "sha256:bf4a44e03040206f7c058d1f5ba02ef2d1820720c88bc4285c7d9a4269f54173"},
{file = "mypy-0.930.tar.gz", hash = "sha256:51426262ae4714cc7dd5439814676e0992b55bcc0f6514eccb4cf8e0678962c2"},
]
mypy-extensions = [
{file = "mypy_extensions-0.4.3-py2.py3-none-any.whl", hash = "sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d"},
{file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
]
nodeenv = [
{file = "nodeenv-1.6.0-py2.py3-none-any.whl", hash = "sha256:621e6b7076565ddcacd2db0294c0381e01fd28945ab36bcf00f41c5daf63bef7"},
{file = "nodeenv-1.6.0.tar.gz", hash = "sha256:3ef13ff90291ba2a4a7a4ff9a979b63ffdd00a464dbe04acf0ea6471517a4c2b"},
@ -1406,6 +1566,42 @@ tomli = [
{file = "tomli-2.0.0-py3-none-any.whl", hash = "sha256:b5bde28da1fed24b9bd1d4d2b8cba62300bfb4ec9a6187a957e8ddb9434c5224"},
{file = "tomli-2.0.0.tar.gz", hash = "sha256:c292c34f58502a1eb2bbb9f5bbc9a5ebc37bee10ffb8c2d6bbdfa8eb13cc14e1"},
]
types-beautifulsoup4 = [
{file = "types-beautifulsoup4-4.10.7.tar.gz", hash = "sha256:8a267194e405b7163bde055ef1120988c0221e5d58e1c5bfe08d601a21e63daf"},
{file = "types_beautifulsoup4-4.10.7-py3-none-any.whl", hash = "sha256:b450b70935b901f500125662ca1856fe6a3ba73ccc88b4fcd577a2b927cc8c14"},
]
types-markdown = [
{file = "types-Markdown-3.3.10.tar.gz", hash = "sha256:d4004e36a33cd3417cd299324232fc28e9915171a7ce46b43bf5b0c579db459d"},
{file = "types_Markdown-3.3.10-py3-none-any.whl", hash = "sha256:0e3153dc4ad3454326465e4e4e21fd90e8fc74966225ec5aeef15895a2c5d94a"},
]
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"},
]
types-setuptools = [
{file = "types-setuptools-57.4.5.tar.gz", hash = "sha256:a4600efdca68a33204ad9c083fd9966d63aee61a7d007e912b6afc6ff57d6e02"},
{file = "types_setuptools-57.4.5-py3-none-any.whl", hash = "sha256:920a7c1ee120025e939a1707f8fd09a1266edbf7848eae7b8de7c5909a824cc8"},
]
types-toml = [
{file = "types-toml-0.10.1.tar.gz", hash = "sha256:5c1f8f8d57692397c8f902bf6b4d913a0952235db7db17d2908cc110e70610cb"},
{file = "types_toml-0.10.1-py3-none-any.whl", hash = "sha256:8cdfd2b7c89bed703158b042dd5cf04255dae77096db66f4a12ca0a93ccb07a5"},
]
typing-extensions = [
{file = "typing_extensions-4.0.1-py3-none-any.whl", hash = "sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b"},
{file = "typing_extensions-4.0.1.tar.gz", hash = "sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e"},
]
tzdata = [
{file = "tzdata-2021.5-py2.py3-none-any.whl", hash = "sha256:3eee491e22ebfe1e5cfcc97a4137cd70f092ce59144d81f8924a844de05ba8f5"},
{file = "tzdata-2021.5.tar.gz", hash = "sha256:68dbe41afd01b867894bbdfd54fa03f468cfa4f0086bfb4adcd8de8f24f3ee21"},

27
pyproject.toml

@ -36,6 +36,14 @@ pytest-rerunfailures = "^10.2"
pytest-env = "^0.6.2"
poetry-deps-scanner = "^1.0.1"
invoke = "^1.6.0"
mypy = "^0.930"
django-stubs = "^1.9.0"
types-Markdown = "^3.3.10"
types-requests = "^2.26.3"
types-setuptools = "^57.4.5"
types-toml = "^0.10.1"
types-beautifulsoup4 = "^4.10.7"
types-Pillow = "^8.3.11"
[tool.black]
target-version = ['py38']
@ -54,6 +62,25 @@ env = [
"GOATCOUNTER_DOMAIN=gc.gabnotes.org"
]
[tool.mypy]
plugins = ["mypy_django_plugin.main"]
mypy_path = "$MYPY_CONFIG_FILE_DIR/stubs:$MYPY_CONFIG_FILE_DIR/src"
exclude = "/migrations/"
[[tool.mypy.overrides]]
module = [
"environ",
"django_otp.plugins.otp_static.models",
"two_factor.models",
"django_otp.plugins.otp_totp.models",
"model_bakery"
]
ignore_missing_imports = true
[tool.django-stubs]
django_settings_module = "blog.settings"
[build-system]
requires = ["poetry-core>=1.0.0"]
build-backend = "poetry.core.masonry.api"

11
src/articles/admin.py

@ -78,6 +78,7 @@ class ArticleAdmin(admin.ModelAdmin):
queryset = queryset.prefetch_related("tags")
return queryset
@admin.action(description="Publish selected articles")
def publish(self, request, queryset):
if not request.user.has_perm("articles.change_article"):
messages.warning(request, "You're not allowed to do this.")
@ -86,8 +87,7 @@ class ArticleAdmin(admin.ModelAdmin):
article.publish()
messages.success(request, f"{len(queryset)} articles published.")
publish.short_description = "Publish selected articles"
@admin.action(description="Unpublish selected articles")
def unpublish(self, request, queryset):
if not request.user.has_perm("articles.change_article"):
messages.warning(request, "You're not allowed to do this.")
@ -96,8 +96,7 @@ class ArticleAdmin(admin.ModelAdmin):
article.unpublish()
messages.success(request, f"{len(queryset)} articles unpublished.")
unpublish.short_description = "Unpublish selected articles"
@admin.action(description="Refresh draft key of selected articles")
def refresh_draft_key(self, request, queryset):
if not request.user.has_perm("articles.change_article"):
messages.warning(request, "You're not allowed to do this.")
@ -106,7 +105,6 @@ class ArticleAdmin(admin.ModelAdmin):
article.refresh_draft_key()
messages.success(request, f"{len(queryset)} draft keys refreshed.")
refresh_draft_key.short_description = "Refresh draft key of selected articles"
actions = [publish, unpublish, refresh_draft_key]
class Media:
@ -134,11 +132,10 @@ class ArticleAdmin(admin.ModelAdmin):
def read_time(self, instance: Article):
return f"{instance.get_read_time()} min"
@admin.display(boolean=True)
def has_custom_css(self, instance: Article):
return bool(instance.custom_css)
has_custom_css.boolean = True
@register(Tag)
class TagAdmin(admin.ModelAdmin):

29
src/articles/context_processors.py

@ -1,4 +1,7 @@
from typing import Any
from django.conf import settings
from django.http import HttpRequest
from articles.models import Article
from attachments.models import Attachment
@ -8,7 +11,7 @@ IGNORED_PATHS = [
]
def drafts_count(request):
def drafts_count(request: HttpRequest) -> dict[str, Any]:
if request.path in IGNORED_PATHS:
return {}
if not request.user.is_authenticated:
@ -16,13 +19,13 @@ def drafts_count(request):
return {"drafts_count": Article.objects.filter(status=Article.DRAFT).count()}
def date_format(request):
def date_format(request: HttpRequest) -> dict[str, Any]:
if request.path in IGNORED_PATHS:
return {}
return {"CUSTOM_ISO": r"Y-m-d\TH:i:sO", "ISO_DATE": "Y-m-d"}
def git_version(request):
def git_version(request: HttpRequest) -> dict[str, Any]:
if request.path in IGNORED_PATHS:
return {}
try:
@ -36,13 +39,13 @@ def git_version(request):
return {"git_version": version, "git_version_url": url}
def analytics(request):
def analytics(request: HttpRequest) -> dict[str, Any]:
return {
"goatcounter_domain": settings.GOATCOUNTER_DOMAIN,
}
def open_graph_image_url(request):
def open_graph_image_url(request: HttpRequest) -> dict[str, Any]:
if request.path in IGNORED_PATHS:
return {}
open_graph_image = Attachment.objects.get_open_graph_image()
@ -52,11 +55,11 @@ def open_graph_image_url(request):
return {"open_graph_image_url": url}
def blog_metadata(request):
context = {}
context["blog_title"] = settings.BLOG["title"]
context["blog_description"] = settings.BLOG["description"]
context["blog_author"] = settings.BLOG["author"]
context["blog_repo_homepage"] = settings.BLOG["repo"]["homepage"]
context["blog_status_url"] = settings.BLOG["status_url"]
return context
def blog_metadata(request: HttpRequest) -> dict[str, Any]:
return {
"blog_title": settings.BLOG["title"],
"blog_description": settings.BLOG["description"],
"blog_author": settings.BLOG["author"],
"blog_repo_homepage": settings.BLOG["repo"]["homepage"],
"blog_status_url": settings.BLOG["status_url"],
}

45
src/articles/models.py

@ -1,6 +1,9 @@
from __future__ import annotations
import random
import uuid
from functools import cached_property
from typing import Sequence
import rcssmin
import readtime
@ -32,16 +35,16 @@ class Tag(models.Model):
class Meta:
ordering = ["name"]
def __str__(self):
def __str__(self) -> str:
return self.name
def get_absolute_url(self):
def get_absolute_url(self) -> str:
return reverse("tag", kwargs={"slug": self.slug})
def get_feed_title(self):
def get_feed_title(self) -> str:
return f"{self.name} - {settings.BLOG['title']}"
def get_feed_url(self):
def get_feed_url(self) -> str:
return reverse("tag-feed", kwargs={"slug": self.slug})
@ -75,65 +78,65 @@ class Article(models.Model):
class Meta:
ordering = ["-published_at"]
def __str__(self):
def __str__(self) -> str:
return self.title
def get_absolute_url(self):
def get_absolute_url(self) -> str:
return reverse("article-detail", kwargs={"slug": self.slug})
def get_mailto_url(self):
def get_mailto_url(self) -> str:
email = settings.BLOG["email"]
return f"mailto:{email}?subject={self.title}"
def get_abstract(self):
def get_abstract(self) -> str:
html = self.get_formatted_content
return html.split("<!--more-->")[0]
@cached_property
def get_description(self):
def get_description(self) -> str:
html = self.get_formatted_content
text = find_first_paragraph_with_text(html)
return truncate_words_after_char_count(text, 160)
@cached_property
def get_formatted_content(self):
def get_formatted_content(self) -> str:
return format_article_content(self.content)
def publish(self):
def publish(self) -> Article:
if not self.published_at:
self.published_at = timezone.now()
self.status = self.PUBLISHED
self.save()
return self
def unpublish(self):
def unpublish(self) -> Article:
self.published_at = None
self.status = self.DRAFT
self.save()
return self
def save(self, *args, **kwargs):
def save(self, *args, **kwargs) -> None:
if not self.slug:
self.slug = slugify(self.title)
return super().save(*args, **kwargs)
@property
def draft_public_url(self):
def draft_public_url(self) -> str:
url = self.get_absolute_url() + f"?draft_key={self.draft_key}"
return build_full_absolute_url(request=None, url=url)
def refresh_draft_key(self):
def refresh_draft_key(self) -> None:
self.draft_key = uuid.uuid4()
self.save()
def get_read_time(self):
def get_read_time(self) -> int:
content = self.get_formatted_content
if content:
return readtime.of_html(content).minutes
return 0
@cached_property
def get_related_articles(self):
def get_related_articles(self) -> Sequence[Article]:
related_articles = set()
published_articles = Article.objects.filter(status=Article.PUBLISHED).exclude(
pk=self.pk
@ -141,19 +144,19 @@ class Article(models.Model):
for tag in self.tags.all().prefetch_related(
Prefetch("articles", published_articles, to_attr="published_articles")
):
related_articles.update(tag.published_articles)
related_articles.update(tag.published_articles) # type: ignore
sample_size = min([len(related_articles), 3])
return random.sample(list(related_articles), sample_size)
@cached_property
def keywords(self):
def keywords(self) -> str:
return ", ".join(map(lambda tag: tag.name, self.tags.all()))
@cached_property
def get_minified_custom_css(self):
def get_minified_custom_css(self) -> str:
return rcssmin.cssmin(self.custom_css)
def get_admin_url(self):
def get_admin_url(self) -> str:
content_type = ContentType.objects.get_for_model(self.__class__)
return reverse(
f"admin:{content_type.app_label}_{content_type.model}_change",

10
src/articles/tests/test_api_views.py

@ -25,11 +25,11 @@ def test_render_article_same_content(published_article: Article, client: Client)
assert api_res.status_code == 200
assert standard_res.status_code == 200
# ignore an expected difference
api_content = api_res.content.decode("utf-8") # type: str
standard_content = standard_res.content.decode("utf-8")
api_content: str = api_res.content.decode("utf-8")
standard_content: str = standard_res.content.decode("utf-8")
api_content = api_content.replace(
"?next=/api/render/1/",
"?next=/some-article-slug/",
"/api/render/1/",
"/some-article-slug/",
)
assert api_content == standard_content
@ -41,7 +41,7 @@ def test_render_article_change_content(published_article: Article, client: Clien
preview_content = "This is a different content **with strong emphasis**"
api_res = post_article(client, published_article, preview_content)
assert api_res.status_code == 200
api_content = api_res.content.decode("utf-8") # type: str
api_content: str = api_res.content.decode("utf-8")
html_preview_content = format_article_content(preview_content)
assert html_preview_content in api_content

2
src/articles/tests/test_html_views.py

@ -26,7 +26,7 @@ def test_only_title_shown_on_list(client: Client, author: User):
status=Article.PUBLISHED,
author=author,
content=f"{abstract}\n<!--more-->\n{after}",
) # type: Article
)
res = client.get(reverse("articles-list"))
content = res.content.decode("utf-8")
assert title in content

22
src/articles/utils.py

@ -9,14 +9,14 @@ from markdown.extensions.toc import TocExtension
from articles.markdown import LazyLoadingImageExtension
def build_full_absolute_url(request, url):
def build_full_absolute_url(request, url: str) -> str:
if request:
return request.build_absolute_uri(url)
else:
return (settings.BLOG["base_url"] + url)[::-1].replace("//", "/", 1)[::-1]
def format_article_content(content):
def format_article_content(content: str) -> str:
md = markdown.Markdown(
extensions=[
"extra",
@ -30,7 +30,7 @@ def format_article_content(content):
return md.convert(content)
def truncate_words_after_char_count(text, char_count):
def truncate_words_after_char_count(text: str, char_count: int) -> str:
total_length = 0
text_result = []
for word in text.split():
@ -41,14 +41,10 @@ def truncate_words_after_char_count(text, char_count):
return " ".join(text_result) + "..."
def find_first_paragraph_with_text(html):
def find_first_paragraph_with_text(html: str) -> str:
bs = BeautifulSoup(html, "html.parser")
paragraph = bs.find("p", recursive=False)
text = paragraph.text.strip()
while not text:
try:
paragraph = paragraph.next_sibling
text = paragraph.text.strip()
except Exception:
break
return text
paragraphs = bs.find_all("p", recursive=False)
for paragraph in paragraphs:
if paragraph.text.strip():
return paragraph.text
return ""

11
src/articles/views/api.py

@ -1,5 +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
@ -8,7 +10,7 @@ from articles.models import Article, Tag
@login_required
@require_POST
def render_article(request, article_pk):
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)
@ -17,10 +19,9 @@ def render_article(request, article_pk):
has_code = request.POST.get("has_code")
if has_code is not None:
article.has_code = has_code == "true"
context = {"article": article}
context: dict[str, Any] = {"article": article}
tags = request.POST.get("tag_ids")
if tags:
tags = Tag.objects.filter(pk__in=map(int, tags.split(",")))
context["tags"] = tags
context["tags"] = Tag.objects.filter(pk__in=map(int, tags.split(",")))
html = render(request, template, context=context)
return HttpResponse(html)

2
src/articles/views/feeds.py

@ -18,7 +18,7 @@ class CompleteFeed(Feed):
def items(self, obj):
return self.get_queryset(obj)[: self.FEED_LIMIT]
def item_description(self, item: Article):
def item_description(self, item: Article): # type: ignore[override]
return item.get_formatted_content
def item_pubdate(self, item: Article):

10
src/articles/views/html.py

@ -50,9 +50,9 @@ class PublicArticleListView(BaseArticleListView):
class ArticlesListView(PublicArticleListView):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
home_article = Article.objects.filter(
home_article: Article = Article.objects.filter(
status=Article.PUBLISHED, is_home=True
).first() # type: Article
).first()
context["article"] = home_article
return context
@ -92,8 +92,8 @@ class SearchArticlesListView(PublicArticleListView):
class TagArticlesListView(PublicArticleListView):
tag = None
main_title = None
html_title = None
main_title = ""
html_title = ""
def dispatch(self, request, *args, **kwargs):
self.tag = get_object_or_404(Tag, slug=self.kwargs.get("slug"))
@ -136,7 +136,7 @@ class ArticleDetailView(generic.DetailView):
return queryset
def get_object(self, queryset=None) -> Article:
obj = super().get_object(queryset) # type: Article
obj: Article = super().get_object(queryset)
if not self.request.user.is_authenticated:
obj.views_count = F("views_count") + 1
obj.save(update_fields=["views_count"])

6
src/attachments/admin.py

@ -56,6 +56,7 @@ class AttachmentAdmin(admin.ModelAdmin):
)
return ""
@admin.action(description="Set as open graph image")
def set_as_open_graph_image(self, request, queryset):
if len(queryset) != 1:
messages.error(request, "You must select only one attachment")
@ -64,8 +65,7 @@ class AttachmentAdmin(admin.ModelAdmin):
queryset.update(open_graph_image=True)
messages.success(request, "Done")
set_as_open_graph_image.short_description = "Set as open graph image"
@admin.action(description="Reprocess selected attachments")
def reprocess_selected_attachments(self, request, queryset):
if len(queryset) == 0:
messages.error(request, "You must select at least one attachment")
@ -73,5 +73,3 @@ class AttachmentAdmin(admin.ModelAdmin):
for attachment in queryset:
attachment.reprocess()
messages.success(request, "Attachments were successfully reprocessed.")
reprocess_selected_attachments.short_description = "Reprocess selected attachments"

4
src/blog/urls.py

@ -13,12 +13,12 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
"""
import debug_toolbar
import debug_toolbar # type: ignore
from django.conf.urls.static import static
from django.contrib import admin
from django.urls import include, path
from django.views.generic import TemplateView
from two_factor.urls import urlpatterns as tf_urls
from two_factor.urls import urlpatterns as tf_urls # type: ignore
from blog import settings

3
stubs/rcssmin.pyi

@ -0,0 +1,3 @@
from typing import Callable
cssmin: Callable[[str], str]

3
stubs/readtime/__init__.pyi

@ -0,0 +1,3 @@
from .api import of_html as of_html
from .api import of_markdown as of_markdown
from .api import of_text as of_text

5
stubs/readtime/api.pyi

@ -0,0 +1,5 @@
from .result import Result
def of_text(text, wpm: int | None = ...) -> Result: ...
def of_html(html, wpm: int | None = ...) -> Result: ...
def of_markdown(markdown, wpm: int | None = ...) -> Result: ...

14
stubs/readtime/result.pyi

@ -0,0 +1,14 @@
from datetime import timedelta
class Result:
delta: timedelta | None
wpm: int | None
def __init__(self, seconds: int | None = ..., wpm: int | None = ...) -> None: ...
def __unicode__(self): ...
@property
def seconds(self) -> int: ...
@property
def minutes(self) -> int: ...
@property
def text(self) -> str: ...
def total_seconds(self, delta) -> int: ...

9
tasks.py

@ -22,6 +22,13 @@ def test_cov(ctx):
)
@task(post=[test_cov])
def check(ctx):
with ctx.cd(BASE_DIR):
ctx.run("pre-commit run --all-files", pty=True)
ctx.run("mypy src", pty=True)
@task
def build(ctx):
with ctx.cd(BASE_DIR):
@ -39,7 +46,7 @@ def deploy(ctx):
ctx.run("ssh ubuntu /home/gaugendre/blog/update", pty=True, echo=True)
@task(pre=[build, publish, deploy])
@task(pre=[check, build, publish, deploy])
def beam(ctx):
pass

Loading…
Cancel
Save