Skip to content

Commit

Permalink
Add tests, fix pyright warnings
Browse files Browse the repository at this point in the history
  • Loading branch information
tomasr8 committed Aug 31, 2024
1 parent cefb43f commit 2fc0645
Show file tree
Hide file tree
Showing 10 changed files with 220 additions and 27 deletions.
4 changes: 2 additions & 2 deletions pyjsx/__init__.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from pyjsx.codecs import register_jsx
from pyjsx.jsx import JSX, jsx
from pyjsx.jsx import JSX, JSXComponent, jsx
from pyjsx.transpiler import transpile


__version__ = "0.1.0"
__all__ = ["register_jsx", "transpile", "jsx", "JSX"]
__all__ = ["register_jsx", "transpile", "jsx", "JSX", "JSXComponent"]
55 changes: 37 additions & 18 deletions pyjsx/jsx.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from collections.abc import Callable
from typing import Any, Self
from __future__ import annotations

from typing import Any, Protocol

from pyjsx.elements import is_void_element
from pyjsx.util import flatten, indent
Expand All @@ -8,11 +9,25 @@
__all__ = ["jsx"]


type JSXComponent = Callable[[dict[str, Any]], Any]
class JSXComponent(Protocol):
__name__: str

def __call__(self, *, children: list[JSX], **rest: Any) -> JSX: ...


class JSXFragment(Protocol):
__name__: str

class _JSXElement:
def __init__(self, tag: str | JSXComponent, props: dict[str, Any], children: list[str | Self]):
def __call__(self, *, children: list[JSX], **rest: Any) -> list[JSX]: ...


class JSXElement:
def __init__(
self,
tag: str | JSXComponent | JSXFragment,
props: dict[str, Any],
children: list[JSX],
):
self.tag = tag
self.props = props
self.children = children
Expand All @@ -31,28 +46,29 @@ def __str__(self):
def convert_prop(self, key: str, value: Any) -> str:
if isinstance(value, bool):
return key if value else ""
value = value.replace('"', """)
value = str(value).replace('"', """)
return f'{key}="{value}"'

def convert_props(self, props: dict[str, Any]) -> str:
formatted = " ".join([self.convert_prop(k, v) for k, v in props.items()])
not_none = {k: v for k, v in props.items() if v is not None}
formatted = " ".join([self.convert_prop(k, v) for k, v in not_none.items()])
if formatted:
return f" {formatted}"
return ""

def convert_builtin(self, tag: str) -> str:
props = self.convert_props(self.props)
if not self.children:
children = [child for child in flatten(self.children) if child is not None]
if not children:
if is_void_element(tag):
return f"<{tag}{props} />"
return f"<{tag}{props}></{tag}>"
children = flatten(self.children)
children = flatten(str(child) for child in children)
children_formatted = "\n".join(indent(child) for child in children)
return f"<{tag}{props}>\n{children_formatted}\n</{tag}>"

def convert_component(self, tag: JSXComponent) -> str:
rendered = tag({**self.props, "children": self.children})
def convert_component(self, tag: JSXComponent | JSXFragment) -> str:
rendered = tag(**self.props, children=self.children)
match rendered:
case None:
return ""
Expand All @@ -65,21 +81,24 @@ def convert_component(self, tag: JSXComponent) -> str:
class _JSX:
def __call__(
self,
tag: str | JSXComponent,
tag: str | JSXComponent | JSXFragment,
props: dict[str, Any] | None = None,
children: list[str | _JSXElement] | None = None,
) -> _JSXElement:
children: list[Any] | None = None,
) -> JSXElement:
if not isinstance(tag, str) and not callable(tag):
msg = f"Element type is invalid. Expected a string or a function but got: {tag!r}"
raise TypeError(msg)
if props is None:
props = {}
if children is None:
children = []
if (style := props.get("style")) and isinstance(style, dict):
props["style"] = "; ".join([f"{k}: {v}" for k, v in style.items()])
return _JSXElement(tag, props, children)
return JSXElement(tag, props, children)

def Fragment(self, props: dict[str, Any]) -> _JSXElement:
return props["children"]
def Fragment(self, *, children: list[Any], **_: Any) -> list[Any]:
return children


jsx = _JSX()
type JSX = _JSXElement
type JSX = JSXElement | str
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[project]
name = "jsx-python"
name = "python-jsx"
authors = [{ name = "Tomas Roun", email = "[email protected]" }]
description = "JSX transpiler for Python"
readme = "README.md"
Expand Down
10 changes: 10 additions & 0 deletions tests/data/examples-custom.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<div>
<h1 style="color: red">
Hello, world!
</h1>
<main>
<p>
This was rendered with PyJSX!
</p>
</main>
</div>
28 changes: 28 additions & 0 deletions tests/data/examples-props.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<div>
<div style="border-radius: 5px; box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1)">
<img src="dog.jpg" alt="A picture of a dog" />
<h1>
Card title
</h1>
<p>
Card content
</p>
</div>
<div style="border-radius: 5px; box-shadow: none">
<img src="cat.jpg" alt="A picture of a cat" />
<h1>
Card title
</h1>
<p>
Card content
</p>
</div>
<div style="border-radius: 5px; box-shadow: none">
<h1>
Card title
</h1>
<p>
Card content
</p>
</div>
</div>
30 changes: 30 additions & 0 deletions tests/data/examples-table.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<table>
<thead>
<tr>
<th>
Name
</th>
<th>
Age
</th>
</tr>
</thead>
<tbody>
<tr>
<td>
Alice
</td>
<td>
34
</td>
</tr>
<tr>
<td>
Bob
</td>
<td>
56
</td>
</tr>
</tbody>
</table>
30 changes: 30 additions & 0 deletions tests/test_examples.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import subprocess
import sys
from pathlib import Path

import pytest


def run_example(name: str):
path = Path(__file__).parents[1] / "examples" / name / "main.py"
# print(path)
return subprocess.run( # noqa: S603
[sys.executable, str(path)], text=True, check=True, capture_output=True
).stdout


@pytest.mark.parametrize(
"example",
[
"table",
"props",
"custom",
],
)
def test_example(request, snapshot, example):
snapshot.snapshot_dir = Path(__file__).parent / "data"
# o = run_example(example)
# print(type(o))
snapshot.assert_match(
run_example(example), f"examples-{request.node.callspec.id}.txt"
)
20 changes: 15 additions & 5 deletions tests/test_jsx.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,16 @@ def test_fragments(source, expected):
(jsx("div", {}, []), "<div></div>"),
(jsx("div", {"foo": "bar"}, []), '<div foo="bar"></div>'),
(jsx("input", {}, []), "<input />"),
(jsx("input", {"type": "number", "disabled": True}, []), '<input type="number" disabled />'),
(
jsx("span", {"style": {"font-size": "14px", "font-weight": "bold"}}, ["text"]),
jsx("input", {"type": "number", "disabled": True}, []),
'<input type="number" disabled />',
),
(
jsx(
"span",
{"style": {"font-size": "14px", "font-weight": "bold"}},
["text"],
),
"""\
<span style="font-size: 14px; font-weight: bold">
text
Expand All @@ -57,8 +64,8 @@ def test_builtins(source, expected):


def test_custom_components():
def Component(props):
return jsx("div", {"class": "wrapper"}, props["children"])
def Component(children, **_):
return jsx("div", {"class": "wrapper"}, children)

source = jsx(Component, {}, ["Hello, world!"])
assert (
Expand All @@ -73,7 +80,10 @@ def Component(props):
@pytest.mark.parametrize(
("source", "expected"),
[
(jsx("input", {"foo": '"should escape"'}, []), '<input foo="&quot;should escape&quot;" />'),
(
jsx("input", {"foo": '"should escape"'}, []),
'<input foo="&quot;should escape&quot;" />',
),
],
)
def test_attribute_escapes(source, expected):
Expand Down
60 changes: 60 additions & 0 deletions tests/test_runtime.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import pytest

from pyjsx import JSX, JSXComponent, jsx, transpile


def run_example(source: str, _locals=None):
py_code = transpile(source)
return eval(py_code, {"jsx": jsx}, _locals) # noqa: S307


def test_passing_jsx_as_props():
def CardWithImageComponent(image: JSX | None = None, **_):
return jsx("div", {}, [image])

def CardWithImageCallable(image: JSXComponent, **_):
return jsx("div", {}, [jsx(image, {}, [])])

def Image(src="example.jpg", alt=None, **_):
return jsx("img", {"src": src, "alt": alt}, [])

html = run_example(
"str(<Card />)", {"Card": CardWithImageComponent, "Image": Image}
)
assert html == "<div></div>"

with pytest.raises(TypeError):
run_example("str(<Card />)", {"Card": CardWithImageCallable, "Image": Image})

html = run_example(
"str(<Card image={<Image src='example.jpg' />} />)",
{"Card": CardWithImageComponent, "Image": Image},
)
assert (
html
== """\
<div>
<img src="example.jpg" />
</div>"""
)

html = run_example(
"str(<Card image={Image} />)", {"Card": CardWithImageCallable, "Image": Image}
)
assert (
html
== """\
<div>
<img src="example.jpg" />
</div>"""
)

with pytest.raises(TypeError) as excinfo:
run_example(
"str(<Card image={<Image />} />)",
{"Card": CardWithImageCallable, "Image": Image},
)
assert (
str(excinfo.value)
== "Element type is invalid. Expected a string or a function but got: <Image />"
)
8 changes: 7 additions & 1 deletion tests/test_transpilation.py
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,9 @@ def App():
)
def test_multiline(snapshot, request, source):
snapshot.snapshot_dir = Path(__file__).parent / "data"
snapshot.assert_match(transpile(source), f"transpiler-multiline-{request.node.callspec.id}.txt")
snapshot.assert_match(
transpile(source), f"transpiler-multiline-{request.node.callspec.id}.txt"
)


@pytest.mark.parametrize(
Expand Down Expand Up @@ -212,6 +214,10 @@ def test_jsx_text_escapes(source, expected):
def _get_stdlib_python_modules():
modules = sys.stdlib_module_names
for name in modules:
if name == "antigravity":
# Importing antigravity opens a web browser which is annoying when running tests
continue

module = None
with contextlib.suppress(Exception):
module = __import__(name)
Expand Down

0 comments on commit 2fc0645

Please sign in to comment.