Skip to content

Commit

Permalink
Merge pull request #18 from tbrlpld/add-type-hints
Browse files Browse the repository at this point in the history
Add type hints
  • Loading branch information
tbrlpld authored Feb 10, 2024
2 parents 111dddf + 98df83e commit 631884e
Show file tree
Hide file tree
Showing 16 changed files with 372 additions and 162 deletions.
4 changes: 4 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,9 @@ exclude_lines =
if 0:
if __name__ == .__main__.:

# Don't complain about if code meant only for type checking isn't run:
if TYPE_CHECKING:
class .*\bProtocol\):

ignore_errors = True
show_missing = True
1 change: 1 addition & 0 deletions .github/pull_request_template.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@
**Checklist**
<-- For each of the following: check `[x]` if fulfilled or mark as irrelevant `[-]` if not applicable. -->
- [ ] [CHANGELOG.md](../CHANGELOG.md) has been updated.
- [ ] Self code reviewed.
3 changes: 2 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@ jobs:
with:
python-version: '3.11'
- name: Install
# Installing test dependencies to make sure that all imports work during type-checking.
run: |
python -m pip install --upgrade pip setuptools wheel
python -m pip install .[dev]
python -m pip install .[testing,dev]
- uses: pre-commit/action@v3.0.0

test:
Expand Down
5 changes: 5 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,8 @@ repos:
entry: flake8
language: system
types: [python]
- id: mypy
name: mypy
entry: mypy
language: system
types: [python]
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

- Add more tests and example usage. ([#6](https://github.com/tbrlpld/laces/pull/6))
- Added support for Python 3.12 and Django 5.0. ([#15](https://github.com/tbrlpld/laces/pull/15))
- Added type hints and type checking with `mypy` in CI. ([#18](https://github.com/tbrlpld/laces/pull/18))

### Changed

Expand Down
42 changes: 35 additions & 7 deletions laces/components.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
from typing import Any, MutableMapping
from typing import TYPE_CHECKING, List

from django.forms import Media, MediaDefiningClass
from django.forms.widgets import Media, MediaDefiningClass
from django.template import Context
from django.template.loader import get_template

from laces.typing import HasMediaProperty


if TYPE_CHECKING:
from typing import Optional

from django.utils.safestring import SafeString

from laces.typing import RenderContext


class Component(metaclass=MediaDefiningClass):
"""
Expand All @@ -18,7 +28,12 @@ class Component(metaclass=MediaDefiningClass):
See also: https://docs.djangoproject.com/en/4.2/topics/forms/media/
"""

def render_html(self, parent_context: MutableMapping[str, Any] = None) -> str:
template_name: str

def render_html(
self,
parent_context: "Optional[RenderContext]" = None,
) -> "SafeString":
"""
Return string representation of the object.
Expand All @@ -40,12 +55,25 @@ def render_html(self, parent_context: MutableMapping[str, Any] = None) -> str:
return template.render(context_data)

def get_context_data(
self, parent_context: MutableMapping[str, Any]
) -> MutableMapping[str, Any]:
self,
parent_context: "RenderContext",
) -> "Optional[RenderContext]":
return {}

# fmt: off
if TYPE_CHECKING:
# It's ugly, I know. But it seems to be the best way to make `mypy` happy.
# The `media` property is dynamically added by the `MediaDefiningClass`
# metaclass. Because of how dynamic it is, `mypy` is not able to pick it up.
# This is why we need to add a type hint for it here. The other way would be a
# stub, but that would require the whole module to be stubbed and that is even
# more annoying to keep up to date.
@property
def media(self) -> Media: ... # noqa: E704
# fmt: on


class MediaContainer(list):
class MediaContainer(List[HasMediaProperty]):
"""
A list that provides a `media` property that combines the media definitions
of its members.
Expand All @@ -64,7 +92,7 @@ class MediaContainer(list):
"""

@property
def media(self):
def media(self) -> Media:
"""
Return a `Media` object containing the media definitions of all members.
Expand Down
33 changes: 22 additions & 11 deletions laces/templatetags/laces.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,17 @@
from typing import TYPE_CHECKING

from django import template
from django.template.base import token_kwargs
from django.template.defaultfilters import conditional_escape
from django.utils.html import conditional_escape
from django.utils.safestring import SafeString


if TYPE_CHECKING:
from typing import Optional

from django.template.base import FilterExpression, Parser, Token

from laces.typing import Renderable


register = template.library.Library()
Expand All @@ -16,19 +27,19 @@ class ComponentNode(template.Node):

def __init__(
self,
component,
extra_context=None,
isolated_context=False,
fallback_render_method=None,
target_var=None,
):
component: "FilterExpression",
extra_context: "Optional[dict[str, FilterExpression]]" = None,
isolated_context: bool = False,
fallback_render_method: "Optional[FilterExpression]" = None,
target_var: "Optional[str]" = None,
) -> None:
self.component = component
self.extra_context = extra_context or {}
self.isolated_context = isolated_context
self.fallback_render_method = fallback_render_method
self.target_var = target_var

def render(self, context: template.Context) -> str:
def render(self, context: template.Context) -> SafeString:
"""
Render the ComponentNode template node.
Expand All @@ -51,7 +62,7 @@ def render(self, context: template.Context) -> str:
The `as` keyword can be used to store the rendered component in a variable
in the parent context. The variable name is passed after the `as` keyword.
"""
component = self.component.resolve(context)
component: "Renderable" = self.component.resolve(context)

if self.fallback_render_method:
fallback_render_method = self.fallback_render_method.resolve(context)
Expand All @@ -75,15 +86,15 @@ def render(self, context: template.Context) -> str:

if self.target_var:
context[self.target_var] = html
return ""
return SafeString("")
else:
if context.autoescape:
html = conditional_escape(html)
return html


@register.tag(name="component")
def component(parser, token):
def component(parser: "Parser", token: "Token") -> ComponentNode:
"""
Template tag to render a component via ComponentNode.
Expand Down
65 changes: 52 additions & 13 deletions laces/test/example/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,47 +6,68 @@
"""

from dataclasses import asdict, dataclass
from typing import TYPE_CHECKING

from django.utils.html import format_html

from laces.components import Component


if TYPE_CHECKING:
from typing import Any, Dict, Optional

from django.utils.safestring import SafeString

from laces.typing import RenderContext


class RendersTemplateWithFixedContentComponent(Component):
template_name = "components/hello-world.html"


class ReturnsFixedContentComponent(Component):
def render_html(self, parent_context=None):
def render_html(
self,
parent_context: "Optional[RenderContext]" = None,
) -> "SafeString":
return format_html("<h1>Hello World Return</h1>\n")


class PassesFixedNameToContextComponent(Component):
template_name = "components/hello-name.html"

def get_context_data(self, parent_context=None):
def get_context_data(
self,
parent_context: "Optional[RenderContext]" = None,
) -> "RenderContext":
return {"name": "Alice"}


class PassesInstanceAttributeToContextComponent(Component):
template_name = "components/hello-name.html"

def __init__(self, name, **kwargs):
def __init__(self, name: str, **kwargs: "Dict[str, Any]") -> None:
super().__init__(**kwargs)
self.name = name

def get_context_data(self, parent_context=None):
def get_context_data(
self,
parent_context: "Optional[RenderContext]" = None,
) -> "RenderContext":
return {"name": self.name}


class PassesSelfToContextComponent(Component):
template_name = "components/hello-self-name.html"

def __init__(self, name, **kwargs):
def __init__(self, name: str, **kwargs: "Dict[str, Any]") -> None:
super().__init__(**kwargs)
self.name = name

def get_context_data(self, parent_context=None):
def get_context_data(
self,
parent_context: "Optional[RenderContext]" = None,
) -> "RenderContext":
return {"self": self}


Expand All @@ -56,14 +77,17 @@ class DataclassAsDictContextComponent(Component):

name: str

def get_context_data(self, parent_context=None):
def get_context_data(
self,
parent_context: "Optional[RenderContext]" = None,
) -> "RenderContext":
return asdict(self)


class PassesNameFromParentContextComponent(Component):
template_name = "components/hello-name.html"

def get_context_data(self, parent_context):
def get_context_data(self, parent_context: "RenderContext") -> "RenderContext":
return {"name": parent_context["name"]}


Expand All @@ -75,7 +99,10 @@ def __init__(self, heading: "HeadingComponent", content: "ParagraphComponent"):
self.heading = heading
self.content = content

def get_context_data(self, parent_context=None):
def get_context_data(
self,
parent_context: "Optional[RenderContext]" = None,
) -> "RenderContext":
return {
"heading": self.heading,
"content": self.content,
Expand All @@ -87,7 +114,10 @@ def __init__(self, text: str):
super().__init__()
self.text = text

def render_html(self, parent_context=None):
def render_html(
self,
parent_context: "Optional[RenderContext]" = None,
) -> "SafeString":
return format_html("<h2>{}</h2>\n", self.text)


Expand All @@ -96,7 +126,10 @@ def __init__(self, text: str):
super().__init__()
self.text = text

def render_html(self, parent_context=None):
def render_html(
self,
parent_context: "Optional[RenderContext]" = None,
) -> "SafeString":
return format_html("<p>{}</p>\n", self.text)


Expand All @@ -108,7 +141,10 @@ def __init__(self, heading: "HeadingComponent", items: "list[Component]"):
self.heading = heading
self.items = items

def get_context_data(self, parent_context=None):
def get_context_data(
self,
parent_context: "Optional[RenderContext]" = None,
) -> "RenderContext":
return {
"heading": self.heading,
"items": self.items,
Expand All @@ -120,5 +156,8 @@ def __init__(self, text: str):
super().__init__()
self.text = text

def render_html(self, parent_context=None):
def render_html(
self,
parent_context: "Optional[RenderContext]" = None,
) -> "SafeString":
return format_html("<blockquote>{}</blockquote>\n", self.text)
8 changes: 7 additions & 1 deletion laces/test/example/views.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from typing import TYPE_CHECKING

from django.shortcuts import render

from laces.test.example.components import (
Expand All @@ -16,7 +18,11 @@
)


def kitchen_sink(request):
if TYPE_CHECKING:
from django.http import HttpRequest, HttpResponse


def kitchen_sink(request: "HttpRequest") -> "HttpResponse":
"""Render a page with all example components."""
fixed_content_template = RendersTemplateWithFixedContentComponent()
fixed_content_return = ReturnsFixedContentComponent()
Expand Down
Loading

0 comments on commit 631884e

Please sign in to comment.