Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ on:


jobs:
quality:
runs-on: ubuntu-latest
container:
image: python:3.14

steps:
- uses: actions/checkout@v7

- name: Run ty
continue-on-error: true
run: |
pip install -e .[testing]
pip install ty
ty check mjml tests

tests_cpython:
runs-on: ubuntu-latest
strategy:
Expand All @@ -26,7 +41,7 @@ jobs:
image: python:${{ matrix.python-version }}

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

- name: Run tests without optional features
run: |
Expand Down Expand Up @@ -60,7 +75,7 @@ jobs:
image: pypy:${{ matrix.pypy-version }}

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7

- name: Run tests without optional features
run: |
Expand Down
23 changes: 13 additions & 10 deletions mjml/core/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
from collections.abc import Mapping
from typing import Any, ClassVar, Optional, Union

from ..lib import merge_dicts
from .registry import components


Expand All @@ -26,13 +25,17 @@ def initComponent(
return component


# Most head components just modify global data structures and return None
# but "mj-head" returns the rendered output of all its children as tuple.
HandlerResult = Union[str, tuple[Optional[str], ...], None]


class Component:
component_name: ClassVar[str]

# LATER: not sure upstream also passes tagName, makes code easier for us
def __init__(self, *, attributes=None, children=(), content: str='',
context: Optional[Mapping[str, Any]],
context: Mapping[str, Any],
props: Optional[dict[str, Any]]=None,
globalAttributes: Optional[dict[str, Any]]=None,
headStyle: Optional[Any]=None,
Expand All @@ -42,14 +45,14 @@ def __init__(self, *, attributes=None, children=(), content: str='',
self.context = context
self.tagName = tagName

self.props = merge_dicts(props or {}, {'children': children, 'content': content})
self.props: dict[str, Any] = {**(props or {}), 'children': children, 'content': content}

# upstream also checks "self.allowed_attrs"
self.attrs = merge_dicts(
self.default_attrs(),
globalAttributes or {},
attributes or {},
)
self.attrs = {
**self.default_attrs(),
**(globalAttributes or {}),
**(attributes or {}),
}

# optional attributes (methods) for some components
if headStyle:
Expand Down Expand Up @@ -84,7 +87,7 @@ def getContent(self) -> str:
return ''
return self.content.strip()

def getChildContext(self) -> dict[str, Any]:
def getChildContext(self) -> Mapping[str, Any]:
return self.context

# js: getAttribute(name)
Expand All @@ -96,7 +99,7 @@ def get_attr(self, name: str, *, missing_ok: bool=False) -> Optional[Any]:
return self.attrs.get(name)
getAttribute = get_attr

def handler(self) -> Optional[str]:
def handler(self) -> HandlerResult:
return None

def render(self) -> str:
Expand Down
27 changes: 15 additions & 12 deletions mjml/elements/_base.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import itertools
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, Optional
from typing import TYPE_CHECKING, Any, Optional, Union

from ..core import Component, initComponent
from ..core.registry import components
from ..helpers import *
from ..lib import merge_dicts


if TYPE_CHECKING:
Expand All @@ -23,7 +22,7 @@ def render(self) -> str:

def getShorthandAttrValue(self,
attribute: str, direction: "Direction",
attr_with_direction: bool=True) -> int:
attr_with_direction: bool=True) -> Union[int, float]:
if attr_with_direction:
mjAttributeDirection = self.getAttribute(f'{attribute}-{direction}')
else:
Expand Down Expand Up @@ -75,10 +74,10 @@ def get_styles(self) -> dict[str, Any]:
return {}

# js: styles(styles)
def styles(self, key: Optional[str]=None) -> str:
def styles(self, key: Optional[Union[str, dict[str, Any]]]=None) -> str:
_styles: Optional[dict[str, Any]] = None

if key and isinstance(key, str):
if isinstance(key, str):
_styles_dict = self.get_styles()
keys = key.split('.')
_styles = _styles_dict.get(keys[0])
Expand All @@ -103,15 +102,15 @@ def serializer(k: str, v: Any) -> Optional[str]:
return style_str

def renderChildren(self, childrens=None, props=None,
renderer: Optional[Callable[[Component], str]]=None,
renderer: Optional[Callable[['BodyComponent'], str]]=None,
attributes=None, rawXML=False) -> str:
if not props:
props = {}
if not renderer:
renderer = lambda component: component.render()
if not attributes:
attributes = {}
childrens = childrens or self.props.get("children")
childrens = childrens or self.props.get("children", ())

if rawXML:
# return childrens.map(child => jsonToXML(child)).join('\n')
Expand Down Expand Up @@ -147,25 +146,29 @@ def renderChildren(self, childrens=None, props=None,
if children is None:
# "comment" node
continue
child_props = merge_dicts(props, {
child_props = {
**props,
'first': (index == 0),
'index': index,
'last': (index+1 == sibling),
'sibling': sibling,
'nonRawSiblings': nonRawSiblings,
})
initialDatas = merge_dicts(children,{
'attributes': merge_dicts(attributes, children['attributes']),
}
initialDatas = {
**children,
'attributes': {**attributes, **children['attributes']},
'context': self.getChildContext(),
'props': child_props,
})
}
initialDatas.pop('tagName')

component = initComponent(
name = children['tagName'],
**initialDatas,
)
if component:
if not isinstance(component, BodyComponent):
raise ValueError(f'Unxpected child component: {component!r}')
output += renderer(component)
index += 1
return output
4 changes: 2 additions & 2 deletions mjml/elements/head/_head_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@


class HeadComponent(Component):
def handlerChildren(self) -> tuple:
def handlerChildren(self) -> tuple[Optional[str], ...]:
def handle_children(children: dict[str, Any]) -> Optional[str]:
tagName = children['tagName']
component = initComponent(
Expand All @@ -27,5 +27,5 @@ def handle_children(children: dict[str, Any]) -> Optional[str]:
return component.render()
return None

childrens = self.props.get("children")
childrens = self.props.get("children", ())
return tuple(map(handle_children, childrens))
2 changes: 1 addition & 1 deletion mjml/elements/head/mj_head.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@ class MjHead(HeadComponent):
component_name: ClassVar[str] = 'mj-head'

@override
def handler(self) -> Optional[str]:
def handler(self) -> tuple[Optional[str], ...]:
return self.handlerChildren()
2 changes: 1 addition & 1 deletion mjml/elements/head/mj_html_attributes.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ class MjHtmlAttributes(HeadComponent):
@override
def handler(self) -> None:
add = self.context['add']
_children = self.props.get("children")
_children = self.props.get("children", ())

for child in _children:
tagName = child['tagName']
Expand Down
3 changes: 2 additions & 1 deletion mjml/elements/mj_accordion_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ def default_attrs(cls):
}

def resolveFontFamily(self):
return resolve_accordion_font_family(self.props, self.context, self.get_attr('font-family'))
font_family_str = self.get_attr('font-family') or ''
return resolve_accordion_font_family(self.props, self.context, font_family_str)

# js: getStyles()
def get_styles(self):
Expand Down
3 changes: 2 additions & 1 deletion mjml/elements/mj_accordion_title.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@ def default_attrs(cls):
}

def resolveFontFamily(self):
return resolve_accordion_font_family(self.props, self.context, self.get_attr('font-family'))
font_family_str = self.get_attr('font-family') or ''
return resolve_accordion_font_family(self.props, self.context, font_family_str)

# js: getStyles()
def get_styles(self):
Expand Down
6 changes: 1 addition & 5 deletions mjml/elements/mj_body.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@

from ..lib import merge_dicts
from ._base import BodyComponent


Expand Down Expand Up @@ -29,10 +28,7 @@ def get_styles(self):
}

def getChildContext(self):
return merge_dicts(
self.context,
{'containerWidth': self.get_attr('width')}
)
return {**self.context, 'containerWidth': self.get_attr('width')}

def render(self):
setBackgroundColor = self.context['setBackgroundColor']
Expand Down
17 changes: 14 additions & 3 deletions mjml/elements/mj_carousel.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@

import random
import string
import typing

from mjml.elements.mj_carousel_image import MjCarouselImage

from ..helpers import msoConditionalTag, widthParser
from ._base import BodyComponent
Expand Down Expand Up @@ -245,8 +248,12 @@ def imagesAttributes(self):
def generateRadios(self):
children = self.props['children']

def _render_radio(component: BodyComponent) -> str:
mj_carousel_image = typing.cast(MjCarouselImage, component)
return mj_carousel_image.renderRadio()

return self.renderChildren(children,
renderer=lambda component: component.renderRadio(),
renderer=_render_radio,
attributes={
'carouselId': self.carouselId,
},
Expand All @@ -257,18 +264,22 @@ def generateThumbnails(self):
if self.getAttribute('thumbnails') != 'visible':
return ''

def _render_thumbnail(component: BodyComponent) -> str:
mj_carousel_image = typing.cast(MjCarouselImage, component)
return mj_carousel_image.renderThumbnail()

return self.renderChildren(children,
attributes={
'tb-border' : self.getAttribute('tb-border'),
'tb-border-radius': self.getAttribute('tb-border-radius'),
'tb-width' : self.thumbnailsWidth(),
'carouselId' : self.carouselId,
},
renderer=lambda component: component.renderThumbnail(),
renderer=_render_thumbnail,
)

def generateControls(self, direction, icon):
iconWidth, _ = widthParser(self.getAttribute('icon-width'))
iconWidth, _ = widthParser(self.getAttribute('icon-width') or '')

img_attrs = self.html_attrs(
src=icon,
Expand Down
24 changes: 18 additions & 6 deletions mjml/elements/mj_column.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@

from ..helpers import parse_float, parse_int, strip_unit, widthParser
from ..lib import merge_dicts
from typing import Literal, Union, overload

from mjml.helpers import WidthUnit, parse_float, parse_int, strip_unit, widthParser

from ._base import BodyComponent


Expand Down Expand Up @@ -86,13 +88,14 @@ def get_styles(self):
'vertical-align': this.getAttribute('vertical-align'),
'width': this.getWidthAsPixel(),
},
'gutter': merge_dicts({
'gutter': {
'padding': this.getAttribute('padding'),
'padding-top': this.getAttribute('padding-top'),
'padding-right': this.getAttribute('padding-right'),
'padding-bottom': this.getAttribute('padding-bottom'),
'padding-left': this.getAttribute('padding-left'),
}, tableStyle),
**tableStyle,
},
}

def getMobileWidth(self):
Expand All @@ -113,7 +116,7 @@ def getMobileWidth(self):

def getWidthAsPixel(self):
containerWidth = self.context['containerWidth']
parsedWidth, unit = widthParser(self.getParsedWidth(True), parseFloatToInt=False)
parsedWidth, unit = widthParser(self.getParsedWidth(toString=True), parseFloatToInt=False)
if unit == '%':
px_width = (parse_float(containerWidth) * parsedWidth) / 100
# we want to render the pixel width as string without decimal digits if possible
Expand Down Expand Up @@ -153,6 +156,15 @@ def getColumnClass(self):
addMediaQuery(className, parsedWidth=parsedWidth, unit=unit)
return className

@overload
def getParsedWidth(self, toString: Literal[False]=False) -> WidthUnit: ...

@overload
def getParsedWidth(self, toString: Literal[True]) -> str: ...

@overload
def getParsedWidth(self, toString: bool) -> Union[WidthUnit, str]: ...

def getParsedWidth(self, toString=False):
this = self
nonRawSiblings = this.props['nonRawSiblings']
Expand Down Expand Up @@ -181,7 +193,7 @@ def getChildContext(self):
else:
width = parsedWidth - allPaddings
containerWidth = f'{width}px'
return merge_dicts(self.context, {'containerWidth': containerWidth})
return {**self.context, 'containerWidth': containerWidth}


def hasGutter(self):
Expand Down
Loading
Loading