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): ''' else: - height_attr = parse_int(self.getAttribute('height')) + height_attr = parse_int(self.getAttribute('height') or '') padding_top = self.getShorthandAttrValue('padding', 'top') padding_bottom = self.getShorthandAttrValue('padding', 'bottom') height = height_attr - padding_top - padding_bottom diff --git a/mjml/elements/mj_section.py b/mjml/elements/mj_section.py index 617543b..b178691 100644 --- a/mjml/elements/mj_section.py +++ b/mjml/elements/mj_section.py @@ -4,7 +4,6 @@ from typing import Any from ..helpers import parse_percentage, strip_unit, suffixCssClasses -from ..lib import merge_dicts from ._base import BodyComponent @@ -79,16 +78,16 @@ def get_styles(self): this = self border_radius = self.getAttribute('border-radius') return { - 'tableFullwidth': merge_dicts({ + 'tableFullwidth': { 'width': '100%', 'border-radius': border_radius, - }, (background if fullWidth else {}) - ), - 'table': merge_dicts({ + **(background if fullWidth else {}), + }, + 'table': { 'width': '100%', 'border-radius': border_radius, - }, ({} if fullWidth else background) - ), + **({} if fullWidth else background), + }, 'td': { 'border': this.getAttribute('border'), 'border-bottom': this.getAttribute('border-bottom'), @@ -104,11 +103,12 @@ def get_styles(self): 'padding-top': this.getAttribute('padding-top'), 'text-align': this.getAttribute('text-align'), }, - 'div': merge_dicts({} if fullWidth else background, { + 'div': { + **({} if fullWidth else background), 'margin': '0px auto', 'border-radius': border_radius, 'max-width': containerWidth, - }), + }, 'innerDiv': { 'line-height': '0', 'font-size': '0', @@ -140,8 +140,7 @@ def getBackgroundString(self): def getChildContext(self): box = self.getBoxWidths()['box'] - child_context = merge_dicts(self.context, {'containerWidth': f'{box}px'}) - return child_context + return {**self.context, 'containerWidth': f'{box}px'} def render(self): if self.isFullWidth(): @@ -196,7 +195,8 @@ def getBackgroundPosition(self) -> dict[str, Any]: } def parseBackgroundPosition(self): - posSplit = self.getAttribute('background-position').split(' ') + background_pos = self.getAttribute('background-position') or '' + posSplit = background_pos.split(' ') if len(posSplit) == 1: val, = posSplit # here we must determine if x or y was provided; other will be center @@ -314,7 +314,7 @@ def renderWithBackground(self, content): 'size' : '1,1', 'aspect': 'atleast' if is_cover else 'atmost', } - elif background_size != 'auto': + elif (background_size is not None) and (background_size != 'auto'): bgSplit = background_size.split(' ') if len(bgSplit) == 1: vSizeAttributes = { diff --git a/mjml/elements/mj_social.py b/mjml/elements/mj_social.py index 5b382b7..da75154 100644 --- a/mjml/elements/mj_social.py +++ b/mjml/elements/mj_social.py @@ -1,4 +1,5 @@ + from ._base import BodyComponent @@ -89,7 +90,7 @@ def renderHorizontal(self): children = self.props['children'] align = self.getAttribute('align') - def render_child(component): + def render_child(component: BodyComponent): if component.isRawElement(): return component.render() table_attrs = component.html_attrs( diff --git a/mjml/helpers/py_utils.py b/mjml/helpers/py_utils.py index 074273e..326d2e6 100644 --- a/mjml/helpers/py_utils.py +++ b/mjml/helpers/py_utils.py @@ -1,7 +1,7 @@ import re from collections.abc import Sequence from decimal import Decimal -from typing import Any, Optional, Union +from typing import Any, Optional, Protocol, Union, cast __all__ = [ @@ -68,11 +68,17 @@ def is_nil(v: Optional[Any]) -> bool: def is_not_nil(v: Optional[Any]) -> bool: return not is_nil(v) + +class Strip(Protocol): + def strip(self) -> object: ... + + def is_empty(v: Optional[Sequence[Any]]) -> bool: if v is None: return True elif hasattr(v, 'strip'): - return not bool(v.strip()) + _strippable_v = cast(Strip, v) + return not bool(_strippable_v.strip()) elif isinstance(v, (int, float)): # Numeric zero is a valid CSS value (e.g. line-height: 0) return False diff --git a/mjml/helpers/width_parser.py b/mjml/helpers/width_parser.py index de93292..b265e2d 100644 --- a/mjml/helpers/width_parser.py +++ b/mjml/helpers/width_parser.py @@ -1,10 +1,10 @@ import re -from typing import NamedTuple, Union +from typing import NamedTuple, Optional, Union from .py_utils import strip_unit -__all__ = ['widthParser'] +__all__ = ['widthParser', 'WidthUnit'] class WidthUnit(NamedTuple): @@ -21,9 +21,11 @@ def __str__(self) -> str: unitRegex = re.compile(r'[\d.,]*(\D*)$') -def widthParser(width: str, parseFloatToInt: bool=True) -> WidthUnit: +def widthParser(width: Optional[str], parseFloatToInt: bool=True) -> WidthUnit: width_str = str(width) match = unitRegex.search(width_str) + if match is None: + raise ValueError(f'No width value found in {width!r}') widthUnit = match.group(1) or 'px' if (widthUnit == '%') and not parseFloatToInt: diff --git a/mjml/lib/__init__.py b/mjml/lib/__init__.py deleted file mode 100644 index 209aa4c..0000000 --- a/mjml/lib/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ - -from .dict_merger import * diff --git a/mjml/lib/dict_merger.py b/mjml/lib/dict_merger.py deleted file mode 100644 index 4172ca8..0000000 --- a/mjml/lib/dict_merger.py +++ /dev/null @@ -1,27 +0,0 @@ -# -*- coding: UTF-8 -*- -# Copyright 2015 Felix Schwarz -# The source code in this file is licensed under the MIT license. - -from typing import Any - - -__all__ = ["merge_dicts"] - -def merge_dicts(*sources: dict[str, Any]) -> dict[str, Any]: - # initial code from - # Robin Bryce, Tue, 19 Dec 2006 - # PSF license - # http://code.activestate.com/recipes/499335-recursively-update-a-dictionary-without-hitting-py/ - result: dict[str, Any] = {} - for source in sources: - stack = [(source, result)] - while stack: - current_src, current_dst = stack.pop() - for key in (current_src or ()): - src_item = current_src.get(key) - dst_item = current_dst.get(key) - if isinstance(src_item, dict) and isinstance(dst_item, dict): - stack.append((src_item, dst_item)) - else: - current_dst[key] = src_item - return result diff --git a/mjml/lib/tests/dict_merger_test.py b/mjml/lib/tests/dict_merger_test.py deleted file mode 100644 index 9147e03..0000000 --- a/mjml/lib/tests/dict_merger_test.py +++ /dev/null @@ -1,21 +0,0 @@ -# -*- coding: UTF-8 -*- -# Copyright 2015 Felix Schwarz -# The source code in this file is licensed under the MIT license. - -from ..dict_merger import merge_dicts - - -def test_returns_single_dict_unmodified(): - assert merge_dicts({}) == {} - assert merge_dicts({'bar': 42}) == {'bar': 42} - -def test_can_merge_two_dicts_without_modifying_inputs(): - a = {'a': 1} - b = {'b': 2} - assert merge_dicts(a, b) == {'a': 1, 'b': 2} - -def test_can_merge_three_dicts_without_modifying_inputs(): - a = {'a': 1} - b = {'b': 2} - c = {'c': 3} - assert merge_dicts(a, b, c) == {'a': 1, 'b': 2, 'c': 3} diff --git a/mjml/mjml2html.py b/mjml/mjml2html.py index 773f059..708efd4 100644 --- a/mjml/mjml2html.py +++ b/mjml/mjml2html.py @@ -11,13 +11,12 @@ from .core import initComponent from .core.registry import register_components, register_core_components from .helpers import json_to_xml, mergeOutlookConditionals, omit, skeleton_str as default_skeleton -from .lib import merge_dicts if TYPE_CHECKING: from _typeshed import StrPath, SupportsRead - from mjml.core.api import Component + from mjml.core.api import Component, HandlerResult T = TypeVar("T") @@ -38,20 +37,26 @@ def mjml_to_html( ) -> ParseResult: register_core_components() - if isinstance(xml_fp_or_json, dict): + if isinstance(xml_fp_or_json, Mapping): xml_fp = StringIO(json_to_xml(xml_fp_or_json)) elif isinstance(xml_fp_or_json, str): xml_fp = StringIO(xml_fp_or_json) + elif isinstance(xml_fp_or_json, bytes): + xml_fp = BytesIO(xml_fp_or_json) else: xml_fp = xml_fp_or_json - if template_dir is None and hasattr(xml_fp, 'name'): - template_dir = Path(xml_fp.name).parent + template_path = getattr(xml_fp, 'name', None) + if (template_dir is None) and isinstance(template_path, (str, PurePath)): + template_dir = Path(template_path).parent - mjml_doc = BeautifulSoup(xml_fp, 'html.parser') - - if (mjml_root := mjml_doc.mjml) is None: - raise ValueError(f"could not parse '{xml_fp.name}'") + mjml_doc = BeautifulSoup(xml_fp.read(), 'html.parser') + mjml_root = mjml_doc.mjml + if mjml_root is None: + if template_path: + raise ValueError(f"Could not parse '{template_path}'") + else: + raise ValueError("Could not parse mjml input") skeleton_path = skeleton if skeleton_path: @@ -104,14 +109,14 @@ def mjml_to_html( mjHead = _find_child(mjml_root, 'mj-head') def processing(node: Optional[Any], context: dict[str, Any], - parseMJML: Optional[Callable[[Any], Any]]=None) -> Optional[str]: + parseMJML: Optional[Callable[[Any], Any]]=None) -> "HandlerResult": if node is None: return None # LATER: upstream passes "parseMJML=identity" for head components # but we can not process lxml nodes here. applyAttributes() seems to do # the right thing though... _mjml_data = parseMJML(node) if parseMJML else applyAttributes(node) - initialDatas = merge_dicts(_mjml_data, {'context': context}) + initialDatas = {**_mjml_data, 'context': context} node_tag = getattr(node, 'name', None) component = initComponent(name=node_tag, **initialDatas) if not component: @@ -126,7 +131,7 @@ def applyAttributes(mjml_element: Any) -> dict[str, Any]: if len(mjml_element) == 0: return {} - def parse(_mjml, parentMjClass: str='', *, template_dir: str) -> Any: + def parse(_mjml, parentMjClass: str='', *, template_dir: Optional["StrPath"]) -> Any: tagName = _mjml.name if isinstance(_mjml, Comment) and keep_comments: comment_text = str(_mjml) @@ -160,16 +165,18 @@ def parse(_mjml, parentMjClass: str='', *, template_dir: str) -> Any: def default_attr_classes(value: Any) -> Any: return globalDatas.get("classesDefault").get(value, {}).get(tagName, {}) - defaultAttributesForClasses = merge_dicts(*map(default_attr_classes, parent_mj_classes)) + defaultAttributesForClasses = {} + for parent_mj_class in parent_mj_classes: + defaultAttributesForClasses |= default_attr_classes(parent_mj_class) nextParentMjClass = attributes.get('mj-class', parentMjClass) _attrs_omit = omit(attributes, 'mj-class') - _returned_attributes = merge_dicts( - globalDatas.get("defaultAttributes").get(tagName, {}), - attributesClasses, - defaultAttributesForClasses, - _attrs_omit, - ) + _returned_attributes = { + **globalDatas.get("defaultAttributes").get(tagName, {}), + **attributesClasses, + **defaultAttributesForClasses, + **_attrs_omit, + } if tagName == 'mj-include': mj_include_subtree = handle_include(attributes['path'], @@ -241,7 +248,8 @@ def _head_data_add(attr, *params): ) globalDatas["headRaw"] = processing(mjHead, headHelpers) content = processing(mjBody, bodyHelpers, applyAttributes) - if content is None: + if not isinstance(content, str): + # basically just a `None` check - only only head components might return a tuple raise ValueError('No content generated!') if attrs := globalDatas.get("htmlAttributes"): diff --git a/mjml/testing_helpers.py b/mjml/testing_helpers.py index 6e86dd6..ce17203 100644 --- a/mjml/testing_helpers.py +++ b/mjml/testing_helpers.py @@ -8,11 +8,10 @@ TESTDATA_DIR = Path(__file__).parent / '..' / 'tests' / 'testdata' -def load_expected_html(test_id, suffix: Union[str, None] = None) -> bytes: +def load_expected_html(test_id, suffix: Union[str, None] = None) -> str: html_filename = f'{test_id}-expected{suffix or ""}.html' - with (TESTDATA_DIR / html_filename).open('rb') as html_fp: - expected_html = html_fp.read() - return expected_html + html_path = TESTDATA_DIR / html_filename + return html_path.read_text() @contextmanager def get_mjml_fp(test_id, json=False): diff --git a/setup.cfg b/setup.cfg index 4aa7993..670b0f3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -47,6 +47,8 @@ install_requires = scripts = mjml/scripts/mjml-html-compare +[options.package_data] +mjml = py.typed [options.packages.find] exclude = diff --git a/tests/missing_functionality_test.py b/tests/missing_functionality_test.py index e5b11cb..cbce445 100644 --- a/tests/missing_functionality_test.py +++ b/tests/missing_functionality_test.py @@ -15,8 +15,8 @@ def test_missing_functionality(test_id): mjml_filename = f'{test_id}.mjml' html_filename = f'{test_id}-expected.html' - with (TESTDATA_DIR / html_filename).open('rb') as html_fp: - expected_html = html_fp.read() + html_path = TESTDATA_DIR / html_filename + expected_html = html_path.read_text() with (TESTDATA_DIR / mjml_filename).open('rb') as mjml_fp: result = mjml_to_html(mjml_fp) diff --git a/tests/mj_button_mailto_link_test.py b/tests/mj_button_mailto_link_test.py index 5841118..4cbd173 100644 --- a/tests/mj_button_mailto_link_test.py +++ b/tests/mj_button_mailto_link_test.py @@ -19,6 +19,7 @@ def test_no_target_for_mailto_links(): result = mjml_to_html(StringIO(mjml)) html = result.html mailto_match = re.search(']*>', html) + assert mailto_match is not None start, end = mailto_match.span() match_str = html[start:end]