diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4349f1b..d89efd5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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: @@ -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: | @@ -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: | diff --git a/mjml/core/api.py b/mjml/core/api.py index 8afd0ce..3d15598 100644 --- a/mjml/core/api.py +++ b/mjml/core/api.py @@ -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 @@ -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, @@ -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: @@ -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) @@ -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: diff --git a/mjml/elements/_base.py b/mjml/elements/_base.py index bfa92e8..8cc80b9 100644 --- a/mjml/elements/_base.py +++ b/mjml/elements/_base.py @@ -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: @@ -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: @@ -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]) @@ -103,7 +102,7 @@ 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 = {} @@ -111,7 +110,7 @@ def renderChildren(self, childrens=None, props=None, 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') @@ -147,18 +146,20 @@ 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( @@ -166,6 +167,8 @@ def renderChildren(self, childrens=None, props=None, **initialDatas, ) if component: + if not isinstance(component, BodyComponent): + raise ValueError(f'Unxpected child component: {component!r}') output += renderer(component) index += 1 return output diff --git a/mjml/elements/head/_head_base.py b/mjml/elements/head/_head_base.py index 8d7135b..0356489 100644 --- a/mjml/elements/head/_head_base.py +++ b/mjml/elements/head/_head_base.py @@ -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( @@ -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)) diff --git a/mjml/elements/head/mj_head.py b/mjml/elements/head/mj_head.py index 836390d..7fa5514 100644 --- a/mjml/elements/head/mj_head.py +++ b/mjml/elements/head/mj_head.py @@ -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() diff --git a/mjml/elements/head/mj_html_attributes.py b/mjml/elements/head/mj_html_attributes.py index 8acbd91..c78aacb 100644 --- a/mjml/elements/head/mj_html_attributes.py +++ b/mjml/elements/head/mj_html_attributes.py @@ -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'] diff --git a/mjml/elements/mj_accordion_text.py b/mjml/elements/mj_accordion_text.py index 542466a..6ed4919 100644 --- a/mjml/elements/mj_accordion_text.py +++ b/mjml/elements/mj_accordion_text.py @@ -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): diff --git a/mjml/elements/mj_accordion_title.py b/mjml/elements/mj_accordion_title.py index c6529fb..8369515 100644 --- a/mjml/elements/mj_accordion_title.py +++ b/mjml/elements/mj_accordion_title.py @@ -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): diff --git a/mjml/elements/mj_body.py b/mjml/elements/mj_body.py index dd6bb90..9754a97 100644 --- a/mjml/elements/mj_body.py +++ b/mjml/elements/mj_body.py @@ -1,5 +1,4 @@ -from ..lib import merge_dicts from ._base import BodyComponent @@ -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'] diff --git a/mjml/elements/mj_carousel.py b/mjml/elements/mj_carousel.py index 8488640..04da326 100644 --- a/mjml/elements/mj_carousel.py +++ b/mjml/elements/mj_carousel.py @@ -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 @@ -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, }, @@ -257,6 +264,10 @@ 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'), @@ -264,11 +275,11 @@ def generateThumbnails(self): '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, diff --git a/mjml/elements/mj_column.py b/mjml/elements/mj_column.py index 8567220..517ae21 100644 --- a/mjml/elements/mj_column.py +++ b/mjml/elements/mj_column.py @@ -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 @@ -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): @@ -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 @@ -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'] @@ -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): diff --git a/mjml/elements/mj_divider.py b/mjml/elements/mj_divider.py index d5e25a6..9765323 100644 --- a/mjml/elements/mj_divider.py +++ b/mjml/elements/mj_divider.py @@ -1,6 +1,5 @@ from ..helpers import parse_int, widthParser -from ..lib import merge_dicts from ._base import BodyComponent @@ -40,8 +39,7 @@ def default_attrs(cls): } def get_styles(self): - _t = tuple - border_attrs = _t(map(lambda k: self.get_attr(f'border-{k}'), ['style', 'width', 'color'])) + border_attrs = [self.get_attr(f'border-{k}') or '' for k in ('style', 'width', 'color')] border_attr_str = ' '.join(border_attrs) p = { 'border-top': border_attr_str, @@ -51,7 +49,7 @@ def get_styles(self): } return { 'p': p, - 'outlook': merge_dicts(p, {'width': self.getOutlookWidth()}), + 'outlook': {**p, 'width': self.getOutlookWidth()}, } def getOutlookWidth(self): diff --git a/mjml/elements/mj_group.py b/mjml/elements/mj_group.py index 8ddeba0..0260bb1 100644 --- a/mjml/elements/mj_group.py +++ b/mjml/elements/mj_group.py @@ -1,6 +1,5 @@ from ..helpers import strip_unit, widthParser -from ..lib import merge_dicts from ._base import BodyComponent @@ -64,7 +63,7 @@ def getChildContext(self): 'containerWidth': containerWidth, 'nonRawSiblings': len(children), } - return merge_dicts(self.context, extra_ctx) + return {**self.context, **extra_ctx} def getParsedWidth(self, toString=False): nonRawSiblings = self.props['nonRawSiblings'] diff --git a/mjml/elements/mj_hero.py b/mjml/elements/mj_hero.py index 45dda82..c3114a5 100644 --- a/mjml/elements/mj_hero.py +++ b/mjml/elements/mj_hero.py @@ -1,6 +1,7 @@ +from typing import Union + from ..helpers import parse_int, widthParser -from ..lib import merge_dicts from ._base import BodyComponent @@ -58,7 +59,7 @@ def getChildContext(self): padding_right = self.getShorthandAttrValue('padding', 'right') paddingSize = padding_left + padding_right - container_width: int = parse_int(containerWidth) + container_width: Union[int, float] = parse_int(containerWidth) parsed_width, unit = widthParser(f'{container_width}px', parseFloatToInt=False) if unit == '%': container_width = (container_width * parsed_width) / 100 - paddingSize @@ -66,17 +67,14 @@ def getChildContext(self): container_width = parsed_width - paddingSize currentContainerWidth = f'{container_width}px' - return merge_dicts( - self.context, - {'containerWidth': currentContainerWidth} - ) + return {**self.context, 'containerWidth': currentContainerWidth} # js: getStyles() def get_styles(self): containerWidth = self.context['containerWidth'] - backgroundHeight = self.get_attr('background-height') - backgroundWidth = self.get_attr('background-width') + backgroundHeight = self.get_attr('background-height') or 0 + backgroundWidth = self.get_attr('background-width') or '' backgroundRatio = round( (parse_int(backgroundHeight) / parse_int(backgroundWidth)) * @@ -97,7 +95,7 @@ def get_styles(self): 'vertical-align' : self.get_attr('vertical-align'), } if self.get_attr('mode') == 'fixed-height': - height_attr = parse_int(self.get_attr('height')) + height_attr = parse_int(self.get_attr('height') or '') padding_top = self.getShorthandAttrValue('padding', 'top') padding_bottom = self.getShorthandAttrValue('padding', 'bottom') height = height_attr - padding_top - padding_bottom @@ -275,7 +273,7 @@ def renderMode(self):