From 40a4a0a3087f320de46b9d1b505c5209bbfe4cd4 Mon Sep 17 00:00:00 2001 From: Gabriel Augendre Date: Tue, 28 Dec 2021 22:31:53 +0100 Subject: [PATCH] Add mypy --- .pre-commit-config.yaml | 8 +- poetry.lock | 198 +++++++++++++++++++++++++- pyproject.toml | 27 ++++ src/articles/admin.py | 11 +- src/articles/context_processors.py | 29 ++-- src/articles/models.py | 45 +++--- src/articles/tests/test_api_views.py | 10 +- src/articles/tests/test_html_views.py | 2 +- src/articles/utils.py | 22 ++- src/articles/views/api.py | 11 +- src/articles/views/feeds.py | 2 +- src/articles/views/html.py | 10 +- src/attachments/admin.py | 6 +- src/blog/urls.py | 4 +- stubs/rcssmin.pyi | 3 + stubs/readtime/__init__.pyi | 3 + stubs/readtime/api.pyi | 5 + stubs/readtime/result.pyi | 14 ++ tasks.py | 9 +- 19 files changed, 336 insertions(+), 83 deletions(-) create mode 100644 stubs/rcssmin.pyi create mode 100644 stubs/readtime/__init__.pyi create mode 100644 stubs/readtime/api.pyi create mode 100644 stubs/readtime/result.pyi diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9eac4b6..452077d 100644 --- a/.pre-commit-config.yaml +++ b/.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] diff --git a/poetry.lock b/poetry.lock index 951630e..b8693ae 100644 --- a/poetry.lock +++ b/poetry.lock @@ -253,6 +253,35 @@ Django = ">=2.2" phonenumbers = ["phonenumbers (>=7.0.2)"] phonenumberslite = ["phonenumberslite (>=7.0.2)"] +[[package]] +name = "django-stubs" +version = "1.9.0" +description = "Mypy stubs for Django" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +django = "*" +django-stubs-ext = ">=0.3.0" +mypy = ">=0.910" +toml = "*" +types-pytz = "*" +types-PyYAML = "*" +typing-extensions = "*" + +[[package]] +name = "django-stubs-ext" +version = "0.3.1" +description = "Monkey-patching and extensions for django-stubs" +category = "dev" +optional = false +python-versions = ">=3.6" + +[package.dependencies] +django = "*" +typing-extensions = "*" + [[package]] name = "django-two-factor-auth" version = "1.13" @@ -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"}, diff --git a/pyproject.toml b/pyproject.toml index 557459d..f341900 100644 --- a/pyproject.toml +++ b/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" diff --git a/src/articles/admin.py b/src/articles/admin.py index cd557c3..2776663 100644 --- a/src/articles/admin.py +++ b/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): diff --git a/src/articles/context_processors.py b/src/articles/context_processors.py index e8c2985..d4d010a 100644 --- a/src/articles/context_processors.py +++ b/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"], + } diff --git a/src/articles/models.py b/src/articles/models.py index a9dc88f..2da61c4 100644 --- a/src/articles/models.py +++ b/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("")[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", diff --git a/src/articles/tests/test_api_views.py b/src/articles/tests/test_api_views.py index 804d2f2..9d05bc0 100644 --- a/src/articles/tests/test_api_views.py +++ b/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 diff --git a/src/articles/tests/test_html_views.py b/src/articles/tests/test_html_views.py index d8a9f82..3e424ff 100644 --- a/src/articles/tests/test_html_views.py +++ b/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\n{after}", - ) # type: Article + ) res = client.get(reverse("articles-list")) content = res.content.decode("utf-8") assert title in content diff --git a/src/articles/utils.py b/src/articles/utils.py index e6add25..e45c7ac 100644 --- a/src/articles/utils.py +++ b/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 "" diff --git a/src/articles/views/api.py b/src/articles/views/api.py index 8fd3423..a6d41c8 100644 --- a/src/articles/views/api.py +++ b/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) diff --git a/src/articles/views/feeds.py b/src/articles/views/feeds.py index dcc0479..d77cd48 100644 --- a/src/articles/views/feeds.py +++ b/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): diff --git a/src/articles/views/html.py b/src/articles/views/html.py index 5e59df2..ce53037 100644 --- a/src/articles/views/html.py +++ b/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"]) diff --git a/src/attachments/admin.py b/src/attachments/admin.py index 016e20c..1bef54b 100644 --- a/src/attachments/admin.py +++ b/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" diff --git a/src/blog/urls.py b/src/blog/urls.py index f572e6d..72c2f90 100644 --- a/src/blog/urls.py +++ b/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 diff --git a/stubs/rcssmin.pyi b/stubs/rcssmin.pyi new file mode 100644 index 0000000..93383d0 --- /dev/null +++ b/stubs/rcssmin.pyi @@ -0,0 +1,3 @@ +from typing import Callable + +cssmin: Callable[[str], str] diff --git a/stubs/readtime/__init__.pyi b/stubs/readtime/__init__.pyi new file mode 100644 index 0000000..9ce3166 --- /dev/null +++ b/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 diff --git a/stubs/readtime/api.pyi b/stubs/readtime/api.pyi new file mode 100644 index 0000000..3d326a1 --- /dev/null +++ b/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: ... diff --git a/stubs/readtime/result.pyi b/stubs/readtime/result.pyi new file mode 100644 index 0000000..f22c6d7 --- /dev/null +++ b/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: ... diff --git a/tasks.py b/tasks.py index 65368e0..8c04306 100644 --- a/tasks.py +++ b/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