├── pyjsx ├── py.typed ├── auto_setup.py ├── mypy.py ├── __init__.py ├── __main__.py ├── codec_hook.py ├── util.py ├── import_hook.py ├── elements.py ├── jsx.py ├── transpiler.py └── tokenizer.py ├── tests ├── __init__.py ├── test_module │ ├── __init__.py │ ├── main.px │ └── escaping.px ├── data │ ├── transpiler-multiline-2.txt │ ├── transpiler-multiline-1.txt │ ├── tokenizer-simple-3.txt │ ├── tokenizer-strings-1.txt │ ├── tokenizer-strings-3.txt │ ├── tokenizer-strings-2.txt │ ├── tokenizer-strings-4.txt │ ├── tokenizer-simple-2.txt │ ├── tokenizer-fstrings-1.txt │ ├── tokenizer-fstrings-2.txt │ ├── tokenizer-custom-elements-4.txt │ ├── tokenizer-element-names-2.txt │ ├── tokenizer-fstrings-3.txt │ ├── tokenizer-fstrings-4.txt │ ├── tokenizer-custom-elements-.txt │ ├── tokenizer-custom-elements-2.txt │ ├── tokenizer-custom-elements-.txt │ ├── tokenizer-strings-5.txt │ ├── examples-custom_components.txt │ ├── tokenizer-custom-elements-.txt │ ├── tokenizer-custom-elements-.txt │ ├── tokenizer-fstrings-5.txt │ ├── examples-custom_elements.txt │ ├── tokenizer-simple-1.txt │ ├── tokenizer-custom-elements-3.txt │ ├── tokenizer-element-names-1.txt │ ├── transpiler-multiline-3.txt │ ├── tokenizer-custom-elements-< │ │ └── foo-->.txt │ ├── tokenizer-custom-elements-< │ │ └── foo->.txt │ ├── tokenizer-custom-elements-1.txt │ ├── tokenizer-custom-elements-< │ │ └── turbo-frame>.txt │ ├── tokenizer-fstrings-8.txt │ ├── tokenizer-simple-4.txt │ ├── tokenizer-fstrings-7.txt │ ├── tokenizer-element-attributes-4.txt │ ├── tokenizer-element-attributes-1.txt │ ├── tokenizer-element-attributes-2.txt │ ├── tokenizer-element-attributes-3.txt │ ├── examples-table.txt │ ├── tokenizer-fstrings-6.txt │ ├── tokenizer-mixed-2.txt │ ├── examples-props.txt │ ├── tokenizer-fstrings-9.txt │ ├── tokenizer-mixed-1.txt │ ├── tokenizer-element-attributes-5.txt │ ├── tokenizer-element-attributes-6.txt │ ├── tokenizer-mixed-3.txt │ ├── tokenizer-nesting-1.txt │ └── tokenizer-mixed-4.txt ├── test_elements.py ├── test_escaping.py ├── test_examples.py ├── test_import_hook.py ├── test_runtime.py ├── test_jsx.py ├── test_tokenizer.py └── test_transpilation.py ├── examples ├── __init__.py ├── props_codec │ ├── __init__.py │ ├── main.py │ └── props.py ├── table_codec │ ├── __init__.py │ ├── main.py │ └── table.py ├── props_import_hook │ ├── __init__.py │ ├── main.py │ └── props.px ├── table_import_hook │ ├── __init__.py │ ├── main.py │ └── table.px ├── custom_elements_codec │ ├── __init__.py │ ├── main.py │ └── custom_elements.py ├── custom_components_codec │ ├── __init__.py │ ├── main.py │ └── custom.py ├── custom_elements_import_hook │ ├── __init__.py │ ├── main.py │ └── custom_elements.px ├── custom_components_import_hook │ ├── __init__.py │ ├── main.py │ └── custom.px └── README.md ├── plugin-vscode ├── .gitignore ├── example.png ├── .vscodeignore ├── CHANGELOG.md ├── .vscode │ └── launch.json ├── README.md ├── package.json ├── vsc-extension-quickstart.md ├── language-configuration.json ├── tags-language-configuration.json ├── LICENSE └── syntaxes │ └── MagicRegExp.tmLanguage.json ├── .gitignore ├── .github ├── dependabot.yml └── workflows │ ├── release.yml │ └── ci.yml ├── CONTRIBUTING.md ├── CHANGELOG.md ├── pyproject.toml ├── README.md ├── logo_bungee_tint.svg └── LICENSE /pyjsx/py.typed: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/props_codec/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/table_codec/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/test_module/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/props_import_hook/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/table_import_hook/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plugin-vscode/.gitignore: -------------------------------------------------------------------------------- 1 | *.vsix 2 | -------------------------------------------------------------------------------- /examples/custom_elements_codec/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/custom_components_codec/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/custom_elements_import_hook/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /examples/custom_components_import_hook/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /plugin-vscode/example.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/tomasr8/pyjsx/HEAD/plugin-vscode/example.png -------------------------------------------------------------------------------- /examples/props_codec/main.py: -------------------------------------------------------------------------------- 1 | import pyjsx.auto_setup 2 | 3 | from props import App 4 | 5 | print(App()) 6 | -------------------------------------------------------------------------------- /plugin-vscode/.vscodeignore: -------------------------------------------------------------------------------- 1 | .vscode/** 2 | .vscode-test/** 3 | .gitignore 4 | vsc-extension-quickstart.md 5 | -------------------------------------------------------------------------------- /tests/test_module/main.px: -------------------------------------------------------------------------------- 1 | from pyjsx import jsx 2 | 3 | def hello(): 4 | return

Hello, World!

5 | -------------------------------------------------------------------------------- /examples/props_import_hook/main.py: -------------------------------------------------------------------------------- 1 | import pyjsx.auto_setup 2 | 3 | from props import App 4 | 5 | print(App()) 6 | -------------------------------------------------------------------------------- /examples/custom_components_codec/main.py: -------------------------------------------------------------------------------- 1 | import pyjsx.auto_setup 2 | 3 | from custom import App 4 | 5 | 6 | print(App()) 7 | -------------------------------------------------------------------------------- /examples/table_codec/main.py: -------------------------------------------------------------------------------- 1 | import pyjsx.auto_setup 2 | 3 | from table import make_table 4 | 5 | 6 | print(make_table()) 7 | -------------------------------------------------------------------------------- /examples/custom_components_import_hook/main.py: -------------------------------------------------------------------------------- 1 | import pyjsx.auto_setup 2 | 3 | from custom import App 4 | 5 | 6 | print(App()) 7 | -------------------------------------------------------------------------------- /examples/custom_elements_codec/main.py: -------------------------------------------------------------------------------- 1 | import pyjsx.auto_setup 2 | 3 | from custom_elements import App 4 | 5 | print(App()) 6 | -------------------------------------------------------------------------------- /tests/data/transpiler-multiline-2.txt: -------------------------------------------------------------------------------- 1 | jsx("ul", {}, [jsx("li", {}, ["First"]), jsx("li", {}, ["Second"]), jsx("li", {}, ["Third"])]) -------------------------------------------------------------------------------- /examples/custom_elements_import_hook/main.py: -------------------------------------------------------------------------------- 1 | import pyjsx.auto_setup 2 | 3 | from custom_elements import App 4 | 5 | print(App()) 6 | -------------------------------------------------------------------------------- /examples/table_import_hook/main.py: -------------------------------------------------------------------------------- 1 | import pyjsx.auto_setup 2 | 3 | from table import make_table 4 | 5 | 6 | print(make_table()) 7 | -------------------------------------------------------------------------------- /tests/data/transpiler-multiline-1.txt: -------------------------------------------------------------------------------- 1 | jsx("div", {}, ["Click", jsx("button", {}, ["here"]), "or", jsx("a", {'href': "example.com"}, ["there"])]) -------------------------------------------------------------------------------- /tests/data/tokenizer-simple-3.txt: -------------------------------------------------------------------------------- 1 | [Token(type=JSX_FRAGMENT_OPEN, value="<>", start=0, end=2), Token(type=JSX_FRAGMENT_CLOSE, value="", start=2, end=5)] 2 | -------------------------------------------------------------------------------- /pyjsx/auto_setup.py: -------------------------------------------------------------------------------- 1 | from pyjsx.codec_hook import register_jsx 2 | from pyjsx.import_hook import register_import_hook 3 | 4 | 5 | register_jsx() 6 | register_import_hook() 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .pytest_cache 3 | .ruff_cache 4 | .mypy_cache 5 | 6 | htmlcov 7 | .coverage 8 | 9 | *.egg-info 10 | dist/ 11 | 12 | venv 13 | .venv 14 | .python-version 15 | -------------------------------------------------------------------------------- /tests/data/tokenizer-strings-1.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=MULTI_LINE_STRING, value="'''\n<>This should not be transpiled\n'''", start=0, end=42), 3 | Token(type=WS, value="\n", start=42, end=43), 4 | ] 5 | -------------------------------------------------------------------------------- /tests/data/tokenizer-strings-3.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=MULTI_LINE_STRING, value='"""\n<>This should not be transpiled\n"""', start=0, end=42), 3 | Token(type=WS, value="\n", start=42, end=43), 4 | ] 5 | -------------------------------------------------------------------------------- /pyjsx/mypy.py: -------------------------------------------------------------------------------- 1 | from mypy.plugin import Plugin 2 | 3 | 4 | class PyJSXPlugin(Plugin): 5 | import pyjsx.auto_setup 6 | 7 | 8 | def plugin(_version: str) -> type[Plugin]: 9 | return PyJSXPlugin 10 | -------------------------------------------------------------------------------- /tests/data/tokenizer-strings-2.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=MULTI_LINE_STRING, value="'''\n
\n Neither this\n {1+2}\n
\n'''", start=0, end=54), 3 | Token(type=WS, value="\n", start=54, end=55), 4 | ] 5 | -------------------------------------------------------------------------------- /tests/data/tokenizer-strings-4.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=MULTI_LINE_STRING, value='"""\n
\n Neither this\n {1+2}\n
\n"""', start=0, end=54), 3 | Token(type=WS, value="\n", start=54, end=55), 4 | ] 5 | -------------------------------------------------------------------------------- /tests/data/tokenizer-simple-2.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="input", start=1, end=6), 4 | Token(type=JSX_SLASH_CLOSE, value="/>", start=7, end=9), 5 | ] 6 | -------------------------------------------------------------------------------- /tests/data/tokenizer-fstrings-1.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=FSTRING_START, value="f'", start=0, end=2), 3 | Token(type=FSTRING_MIDDLE, value="test", start=6, end=10), 4 | Token(type=FSTRING_END, value="'", start=6, end=7), 5 | ] 6 | -------------------------------------------------------------------------------- /tests/data/tokenizer-fstrings-2.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=FSTRING_START, value='f"', start=0, end=2), 3 | Token(type=FSTRING_MIDDLE, value="test", start=6, end=10), 4 | Token(type=FSTRING_END, value='"', start=6, end=7), 5 | ] 6 | -------------------------------------------------------------------------------- /tests/data/tokenizer-custom-elements-4.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="foo-", start=1, end=5), 4 | Token(type=JSX_SLASH_CLOSE, value="/>", start=6, end=8), 5 | ] 6 | -------------------------------------------------------------------------------- /tests/data/tokenizer-element-names-2.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="a.b.c", start=1, end=6), 4 | Token(type=JSX_SLASH_CLOSE, value="/>", start=7, end=9), 5 | ] 6 | -------------------------------------------------------------------------------- /tests/data/tokenizer-fstrings-3.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=FSTRING_START, value="f'''", start=0, end=4), 3 | Token(type=FSTRING_MIDDLE, value="test", start=8, end=12), 4 | Token(type=FSTRING_END, value="'''", start=8, end=11), 5 | ] 6 | -------------------------------------------------------------------------------- /tests/data/tokenizer-fstrings-4.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=FSTRING_START, value='f"""', start=0, end=4), 3 | Token(type=FSTRING_MIDDLE, value="test", start=8, end=12), 4 | Token(type=FSTRING_END, value='"""', start=8, end=11), 5 | ] 6 | -------------------------------------------------------------------------------- /tests/data/tokenizer-custom-elements-.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="foo-", start=1, end=5), 4 | Token(type=JSX_SLASH_CLOSE, value="/>", start=6, end=8), 5 | ] 6 | -------------------------------------------------------------------------------- /tests/data/tokenizer-custom-elements-2.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="turbo-frame", start=1, end=12), 4 | Token(type=JSX_SLASH_CLOSE, value="/>", start=13, end=15), 5 | ] 6 | -------------------------------------------------------------------------------- /tests/data/tokenizer-custom-elements-.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="foo--", start=1, end=6), 4 | Token(type=JSX_SLASH_CLOSE, value="/>", start=7, end=9), 5 | ] 6 | -------------------------------------------------------------------------------- /tests/data/tokenizer-strings-5.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token( 3 | type=MULTI_LINE_STRING, value='rB"""\n
\n Neither this\n {1+2}\n
\n"""', start=0, end=56 4 | ), 5 | Token(type=WS, value="\n", start=56, end=57), 6 | ] 7 | -------------------------------------------------------------------------------- /tests/data/examples-custom_components.txt: -------------------------------------------------------------------------------- 1 |
2 |

3 | Hello, world! 4 |

5 |
6 |

7 | This was rendered with PyJSX! 8 |

9 |
10 |
11 | -------------------------------------------------------------------------------- /tests/data/tokenizer-custom-elements-.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="turbo-frame", start=1, end=12), 4 | Token(type=JSX_SLASH_CLOSE, value="/>", start=13, end=15), 5 | ] 6 | -------------------------------------------------------------------------------- /plugin-vscode/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to the "pyjsx" extension will be documented in this file. 4 | 5 | Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file. 6 | 7 | ## 0.0.1 8 | 9 | * Initial release 10 | -------------------------------------------------------------------------------- /pyjsx/__init__.py: -------------------------------------------------------------------------------- 1 | from pyjsx.codec_hook import register_jsx 2 | from pyjsx.jsx import JSX, HTMLDontEscape, JSXComponent, jsx 3 | from pyjsx.transpiler import transpile 4 | 5 | 6 | __version__ = "0.4.0" 7 | __all__ = ["JSX", "HTMLDontEscape", "JSXComponent", "jsx", "register_jsx", "transpile"] 8 | -------------------------------------------------------------------------------- /tests/data/tokenizer-custom-elements-.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="foo-", start=1, end=5), 4 | Token(type=ATTRIBUTE, value=".", start=5, end=6), 5 | Token(type=JSX_SLASH_CLOSE, value="/>", start=7, end=9), 6 | ] 7 | -------------------------------------------------------------------------------- /tests/data/tokenizer-fstrings-5.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=FSTRING_START, value="f'", start=0, end=2), 3 | Token(type=OP, value="{", start=2, end=3), 4 | Token(type=ANY, value="1", start=3, end=4), 5 | Token(type=OP, value="}", start=4, end=5), 6 | Token(type=FSTRING_END, value="'", start=5, end=6), 7 | ] 8 | -------------------------------------------------------------------------------- /tests/data/examples-custom_elements.txt: -------------------------------------------------------------------------------- 1 | 2 |

3 | Hello, world! 4 |

5 |
6 | 7 |

8 | This is a custom elements example. 9 |

10 |
11 | 12 |

13 | © 2025 My Company 14 |

15 |
16 | -------------------------------------------------------------------------------- /tests/test_module/escaping.px: -------------------------------------------------------------------------------- 1 | from pyjsx import jsx, HTMLDontEscape 2 | 3 | def hello(): 4 | dangerous_attr = 'x" onclick="alert(1)"' 5 | bad_key = {'quote"': True} 6 | dangerous_text = "" 7 | safe_br = HTMLDontEscape("
") 8 | return

Hello, World! {safe_br} {dangerous_text}

9 | -------------------------------------------------------------------------------- /tests/data/tokenizer-simple-1.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="div", start=1, end=4), 4 | Token(type=JSX_CLOSE, value=">", start=4, end=5), 5 | Token(type=JSX_SLASH_OPEN, value="", start=10, end=11), 8 | ] 9 | -------------------------------------------------------------------------------- /tests/data/tokenizer-custom-elements-3.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="foo-", start=1, end=5), 4 | Token(type=JSX_CLOSE, value=">", start=5, end=6), 5 | Token(type=JSX_SLASH_OPEN, value="", start=12, end=13), 8 | ] 9 | -------------------------------------------------------------------------------- /tests/data/tokenizer-element-names-1.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="a.b.c", start=1, end=6), 4 | Token(type=JSX_CLOSE, value=">", start=6, end=7), 5 | Token(type=JSX_SLASH_OPEN, value="", start=14, end=15), 8 | ] 9 | -------------------------------------------------------------------------------- /tests/data/transpiler-multiline-3.txt: -------------------------------------------------------------------------------- 1 | def Header(props): 2 | title = props["title"] 3 | return jsx("h1", {'data-x': "123", 'style': {'font-size': '12px'}}, [title]) 4 | 5 | 6 | def Body(props): 7 | return jsx("div", {'class': "body"}, [props["children"]]) 8 | 9 | 10 | def App(): 11 | return ( 12 | jsx(Body, {}, ["some text", jsx(Header, {'title': "Home"}, []), "more text"]) 13 | ) -------------------------------------------------------------------------------- /tests/data/tokenizer-custom-elements-.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="foo--", start=1, end=6), 4 | Token(type=JSX_CLOSE, value=">", start=6, end=7), 5 | Token(type=JSX_SLASH_OPEN, value="", start=14, end=15), 8 | ] 9 | -------------------------------------------------------------------------------- /tests/data/tokenizer-custom-elements-.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="foo-", start=1, end=5), 4 | Token(type=JSX_CLOSE, value=">", start=5, end=6), 5 | Token(type=JSX_SLASH_OPEN, value="", start=12, end=13), 8 | ] 9 | -------------------------------------------------------------------------------- /tests/data/tokenizer-custom-elements-1.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="turbo-frame", start=1, end=12), 4 | Token(type=JSX_CLOSE, value=">", start=12, end=13), 5 | Token(type=JSX_SLASH_OPEN, value="", start=26, end=27), 8 | ] 9 | -------------------------------------------------------------------------------- /tests/data/tokenizer-custom-elements-.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="turbo-frame", start=1, end=12), 4 | Token(type=JSX_CLOSE, value=">", start=12, end=13), 5 | Token(type=JSX_SLASH_OPEN, value="", start=26, end=27), 8 | ] 9 | -------------------------------------------------------------------------------- /tests/data/tokenizer-fstrings-8.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=FSTRING_START, value='f"""', start=0, end=4), 3 | Token(type=FSTRING_MIDDLE, value="\nHello, ", start=12, end=20), 4 | Token(type=OP, value="{", start=12, end=13), 5 | Token(type=NAME, value="world", start=13, end=18), 6 | Token(type=OP, value="}", start=18, end=19), 7 | Token(type=FSTRING_MIDDLE, value="!\n", start=21, end=23), 8 | Token(type=FSTRING_END, value='"""', start=21, end=24), 9 | ] 10 | -------------------------------------------------------------------------------- /tests/data/tokenizer-simple-4.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="div", start=1, end=4), 4 | Token(type=JSX_CLOSE, value=">", start=4, end=5), 5 | Token(type=JSX_TEXT, value="Hello, world!", start=5, end=18), 6 | Token(type=JSX_SLASH_OPEN, value="", start=23, end=24), 9 | ] 10 | -------------------------------------------------------------------------------- /examples/custom_elements_import_hook/custom_elements.px: -------------------------------------------------------------------------------- 1 | from pyjsx import jsx, JSX 2 | 3 | 4 | def App() -> JSX: 5 | return ( 6 | <> 7 | 8 |

Hello, world!

9 |
10 | 11 |

This is a custom elements example.

12 |
13 | 14 |

© 2025 My Company

15 |
16 | 17 | ) 18 | -------------------------------------------------------------------------------- /examples/custom_elements_codec/custom_elements.py: -------------------------------------------------------------------------------- 1 | # coding: jsx 2 | from pyjsx import jsx, JSX 3 | 4 | 5 | def App() -> JSX: 6 | return ( 7 | <> 8 | 9 |

Hello, world!

10 |
11 | 12 |

This is a custom elements example.

13 |
14 | 15 |

© 2025 My Company

16 |
17 | 18 | ) 19 | -------------------------------------------------------------------------------- /tests/data/tokenizer-fstrings-7.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=FSTRING_START, value='f"', start=0, end=2), 3 | Token(type=OP, value="{", start=2, end=3), 4 | Token(type=FSTRING_START, value='f"', start=3, end=5), 5 | Token(type=OP, value="{", start=5, end=6), 6 | Token(type=ANY, value="1", start=6, end=7), 7 | Token(type=OP, value="}", start=7, end=8), 8 | Token(type=FSTRING_END, value='"', start=8, end=9), 9 | Token(type=OP, value="}", start=9, end=10), 10 | Token(type=FSTRING_END, value='"', start=10, end=11), 11 | ] 12 | -------------------------------------------------------------------------------- /examples/custom_components_import_hook/custom.px: -------------------------------------------------------------------------------- 1 | from pyjsx import jsx, JSX 2 | 3 | 4 | def Header(children, style=None, **rest) -> JSX: 5 | return

{children}

6 | 7 | 8 | def Main(children, **rest) -> JSX: 9 | return
{children}
10 | 11 | 12 | def App() -> JSX: 13 | return ( 14 |
15 |
Hello, world!
16 |
17 |

This was rendered with PyJSX!

18 |
19 |
20 | ) 21 | -------------------------------------------------------------------------------- /tests/data/tokenizer-element-attributes-4.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="button", start=1, end=7), 4 | Token(type=ATTRIBUTE, value="disabled", start=8, end=16), 5 | Token(type=JSX_CLOSE, value=">", start=16, end=17), 6 | Token(type=JSX_TEXT, value="Hello, world!", start=17, end=30), 7 | Token(type=JSX_SLASH_OPEN, value="", start=38, end=39), 10 | ] 11 | -------------------------------------------------------------------------------- /examples/custom_components_codec/custom.py: -------------------------------------------------------------------------------- 1 | # coding: jsx 2 | from pyjsx import jsx, JSX 3 | 4 | 5 | def Header(children, style=None, **rest) -> JSX: 6 | return

{children}

7 | 8 | 9 | def Main(children, **rest) -> JSX: 10 | return
{children}
11 | 12 | 13 | def App() -> JSX: 14 | return ( 15 |
16 |
Hello, world!
17 |
18 |

This was rendered with PyJSX!

19 |
20 |
21 | ) 22 | -------------------------------------------------------------------------------- /tests/test_elements.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pyjsx.elements import is_builtin_element 4 | 5 | 6 | @pytest.mark.parametrize( 7 | ("elem", "is_builtin"), 8 | [ 9 | ("a", True), 10 | ("div", True), 11 | ("input", True), 12 | ("foo", False), 13 | ("Component", False), 14 | ("custom-element", True), 15 | ("foo-", True), 16 | ("-bar", True), 17 | ("-", True), 18 | ], 19 | ) 20 | def test_builtin_elements(elem: str, *, is_builtin: bool): 21 | assert is_builtin_element(elem) == is_builtin 22 | -------------------------------------------------------------------------------- /tests/data/tokenizer-element-attributes-1.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="div", start=1, end=4), 4 | Token(type=ATTRIBUTE, value="foo", start=5, end=8), 5 | Token(type=OP, value="=", start=8, end=9), 6 | Token(type=ATTRIBUTE_VALUE, value="'bar'", start=9, end=14), 7 | Token(type=JSX_CLOSE, value=">", start=14, end=15), 8 | Token(type=JSX_SLASH_OPEN, value="", start=20, end=21), 11 | ] 12 | -------------------------------------------------------------------------------- /tests/data/tokenizer-element-attributes-2.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="div", start=1, end=4), 4 | Token(type=ATTRIBUTE, value="foo", start=5, end=8), 5 | Token(type=OP, value="=", start=8, end=9), 6 | Token(type=ATTRIBUTE_VALUE, value='"bar"', start=9, end=14), 7 | Token(type=JSX_CLOSE, value=">", start=14, end=15), 8 | Token(type=JSX_SLASH_OPEN, value="", start=20, end=21), 11 | ] 12 | -------------------------------------------------------------------------------- /tests/data/tokenizer-element-attributes-3.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="input", start=1, end=6), 4 | Token(type=ATTRIBUTE, value="type", start=7, end=11), 5 | Token(type=OP, value="=", start=11, end=12), 6 | Token(type=ATTRIBUTE_VALUE, value="'number'", start=12, end=20), 7 | Token(type=ATTRIBUTE, value="value", start=21, end=26), 8 | Token(type=OP, value="=", start=26, end=27), 9 | Token(type=ATTRIBUTE_VALUE, value="'2'", start=27, end=30), 10 | Token(type=JSX_SLASH_CLOSE, value="/>", start=31, end=33), 11 | ] 12 | -------------------------------------------------------------------------------- /examples/README.md: -------------------------------------------------------------------------------- 1 | # Examples 2 | 3 | > [!TIP] 4 | > Run each example with `python main.py` 5 | 6 | The examples showcase the two supported ways of running JSX in Python. 7 | Examples with `_codec` show how to use a custom codec. Examples with `_import_hook` show how to use an import hook. 8 | 9 | - `table` - Shows how you can easily generate an HTML table from data 10 | - `custom_components` - Shows how you can use custom components 11 | - `props` - Shows some advanced props usage 12 | - `custom_elements` - Shows how you can use [custom HTML elements](https://developer.mozilla.org/en-US/docs/Web/API/Web_components/Using_custom_elements) 13 | -------------------------------------------------------------------------------- /plugin-vscode/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | // A launch configuration that launches the extension inside a new window 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | { 6 | "version": "0.2.0", 7 | "configurations": [ 8 | { 9 | "name": "Extension", 10 | "type": "extensionHost", 11 | "request": "launch", 12 | "args": [ 13 | "--extensionDevelopmentPath=${workspaceFolder}" 14 | ] 15 | } 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /tests/data/examples-table.txt: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 7 | 10 | 11 | 12 | 13 | 14 | 17 | 20 | 21 | 22 | 25 | 28 | 29 | 30 |
5 | Name 6 | 8 | Age 9 |
15 | Alice 16 | 18 | 34 19 |
23 | Bob 24 | 26 | 56 27 |
31 | -------------------------------------------------------------------------------- /pyjsx/__main__.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from pathlib import Path 3 | 4 | from pyjsx import transpile 5 | 6 | 7 | def transpile_file(f: Path) -> None: 8 | print("Transpiling", f) # noqa: T201 9 | source = f.read_text("utf-8") 10 | new_file = f.parent / f"{f.stem}_transpiled{f.suffix}" 11 | new_file.write_text(transpile(source)) 12 | 13 | 14 | if __name__ == "__main__": 15 | for p in sys.argv[1:]: 16 | path = Path(p) 17 | if not path.exists(): 18 | continue 19 | if path.is_dir(): 20 | for f in path.rglob("*.py"): 21 | transpile_file(f) 22 | else: 23 | transpile_file(path) 24 | -------------------------------------------------------------------------------- /tests/test_escaping.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pyjsx.import_hook import register_import_hook, unregister_import_hook 4 | 5 | 6 | @pytest.fixture 7 | def import_hook(): 8 | register_import_hook() 9 | yield 10 | unregister_import_hook() 11 | 12 | 13 | @pytest.mark.usefixtures("import_hook") 14 | def test_import(): 15 | from .test_module import escaping # type: ignore[reportAttributeAccessIssue,attr-defined,unused-ignore] 16 | 17 | assert ( 18 | str(escaping.hello()) 19 | == """\ 20 |

21 | Hello, World! 22 |
23 | <script></script> 24 |

""" 25 | ) 26 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | day: "monday" 8 | time: "09:00" 9 | timezone: "Europe/Zurich" 10 | cooldown: 11 | default-days: 7 12 | groups: 13 | github-actions: 14 | patterns: ["*"] 15 | 16 | - package-ecosystem: "pip" 17 | directory: "/" 18 | schedule: 19 | interval: "weekly" 20 | day: "monday" 21 | time: "09:00" 22 | timezone: "Europe/Zurich" 23 | cooldown: 24 | default-days: 7 25 | groups: 26 | python-dependencies: 27 | patterns: ["*"] 28 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Upload To PyPI 2 | 3 | on: 4 | release: 5 | types: [published] 6 | workflow_dispatch: 7 | 8 | permissions: 9 | contents: read 10 | 11 | jobs: 12 | pypi-publish: 13 | 14 | runs-on: ubuntu-latest 15 | environment: release 16 | permissions: 17 | id-token: write 18 | 19 | steps: 20 | - uses: actions/checkout@v6 21 | 22 | - name: Install the latest version of uv and set the python version 23 | uses: astral-sh/setup-uv@v7 24 | with: 25 | python-version: '3.14' 26 | 27 | - name: Build package 📦 28 | run: uv build 29 | 30 | - name: Publish package distributions to PyPI 31 | uses: pypa/gh-action-pypi-publish@release/v1 32 | -------------------------------------------------------------------------------- /tests/data/tokenizer-fstrings-6.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=FSTRING_START, value='f"', start=0, end=2), 3 | Token(type=OP, value="{", start=2, end=3), 4 | Token(type=ANY, value="1", start=3, end=4), 5 | Token(type=OP, value="}", start=4, end=5), 6 | Token(type=FSTRING_MIDDLE, value="+", start=6, end=7), 7 | Token(type=OP, value="{", start=6, end=7), 8 | Token(type=ANY, value="1", start=7, end=8), 9 | Token(type=OP, value="}", start=8, end=9), 10 | Token(type=FSTRING_MIDDLE, value="=", start=10, end=11), 11 | Token(type=OP, value="{", start=10, end=11), 12 | Token(type=ANY, value="2", start=11, end=12), 13 | Token(type=OP, value="}", start=12, end=13), 14 | Token(type=FSTRING_END, value='"', start=13, end=14), 15 | ] 16 | -------------------------------------------------------------------------------- /tests/data/tokenizer-mixed-2.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="Header", start=1, end=7), 4 | Token(type=JSX_OPEN_BRACE, value="{", start=8, end=9), 5 | Token(type=JSX_SPREAD, value="...", start=9, end=12), 6 | Token(type=NAME, value="props", start=12, end=17), 7 | Token(type=JSX_CLOSE_BRACE, value="}", start=17, end=18), 8 | Token(type=JSX_CLOSE, value=">", start=18, end=19), 9 | Token(type=JSX_OPEN_BRACE, value="{", start=19, end=20), 10 | Token(type=NAME, value="title", start=20, end=25), 11 | Token(type=JSX_CLOSE_BRACE, value="}", start=25, end=26), 12 | Token(type=JSX_TEXT, value=" /", start=26, end=28), 13 | Token(type=JSX_CLOSE, value=">", start=28, end=29), 14 | ] 15 | -------------------------------------------------------------------------------- /tests/data/examples-props.txt: -------------------------------------------------------------------------------- 1 |
2 |
3 | A picture of a dog 4 |

5 | Card title 6 |

7 |

8 | Card content 9 |

10 |
11 |
12 | A picture of a cat 13 |

14 | Card title 15 |

16 |

17 | Card content 18 |

19 |
20 |
21 |

22 | Card title 23 |

24 |

25 | Card content 26 |

27 |
28 |
29 | -------------------------------------------------------------------------------- /tests/data/tokenizer-fstrings-9.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=FSTRING_START, value='f"', start=0, end=2), 3 | Token(type=FSTRING_MIDDLE, value="Hello, ", start=9, end=16), 4 | Token(type=OP, value="{", start=9, end=10), 5 | Token(type=JSX_OPEN, value="<", start=10, end=11), 6 | Token(type=ELEMENT_NAME, value="b", start=11, end=12), 7 | Token(type=JSX_CLOSE, value=">", start=12, end=13), 8 | Token(type=JSX_TEXT, value="world", start=13, end=18), 9 | Token(type=JSX_SLASH_OPEN, value="", start=21, end=22), 12 | Token(type=OP, value="}", start=22, end=23), 13 | Token(type=FSTRING_MIDDLE, value="!", start=24, end=25), 14 | Token(type=FSTRING_END, value='"', start=24, end=25), 15 | ] 16 | -------------------------------------------------------------------------------- /examples/table_import_hook/table.px: -------------------------------------------------------------------------------- 1 | from pyjsx import jsx, JSX 2 | 3 | 4 | def make_header(names: list[str]) -> JSX: 5 | return ( 6 | 7 | 8 | {{name} for name in names} 9 | 10 | 11 | ) 12 | 13 | 14 | def make_body(rows: list[list[str]]) -> JSX: 15 | return ( 16 | 17 | { 18 | 19 | {{cell} for cell in row} 20 | 21 | for row in rows 22 | } 23 | 24 | ) 25 | 26 | 27 | def make_table() -> JSX: 28 | columns = ["Name", "Age"] 29 | rows = [["Alice", "34"], ["Bob", "56"]] 30 | 31 | return ( 32 | 33 | {make_header(columns)} 34 | {make_body(rows)} 35 |
36 | ) 37 | -------------------------------------------------------------------------------- /examples/table_codec/table.py: -------------------------------------------------------------------------------- 1 | # coding: jsx 2 | from pyjsx import jsx, JSX 3 | 4 | 5 | def make_header(names: list[str]) -> JSX: 6 | return ( 7 | 8 | 9 | {{name} for name in names} 10 | 11 | 12 | ) 13 | 14 | 15 | def make_body(rows: list[list[str]]) -> JSX: 16 | return ( 17 | 18 | { 19 | 20 | {{cell} for cell in row} 21 | 22 | for row in rows 23 | } 24 | 25 | ) 26 | 27 | 28 | def make_table() -> JSX: 29 | columns = ["Name", "Age"] 30 | rows = [["Alice", "34"], ["Bob", "56"]] 31 | 32 | return ( 33 | 34 | {make_header(columns)} 35 | {make_body(rows)} 36 |
37 | ) 38 | -------------------------------------------------------------------------------- /tests/data/tokenizer-mixed-1.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="a", start=1, end=2), 4 | Token(type=ATTRIBUTE, value="href", start=3, end=7), 5 | Token(type=OP, value="=", start=7, end=8), 6 | Token(type=JSX_OPEN_BRACE, value="{", start=8, end=9), 7 | Token(type=NAME, value="href", start=9, end=13), 8 | Token(type=JSX_CLOSE_BRACE, value="}", start=13, end=14), 9 | Token(type=JSX_CLOSE, value=">", start=14, end=15), 10 | Token(type=JSX_OPEN_BRACE, value="{", start=15, end=16), 11 | Token(type=NAME, value="link_title", start=16, end=26), 12 | Token(type=JSX_CLOSE_BRACE, value="}", start=26, end=27), 13 | Token(type=JSX_SLASH_OPEN, value="", start=30, end=31), 16 | ] 17 | -------------------------------------------------------------------------------- /tests/data/tokenizer-element-attributes-5.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="Component", start=1, end=10), 4 | Token(type=ATTRIBUTE, value="wrapper", start=11, end=18), 5 | Token(type=OP, value="=", start=18, end=19), 6 | Token(type=JSX_OPEN, value="<", start=19, end=20), 7 | Token(type=ELEMENT_NAME, value="div", start=20, end=23), 8 | Token(type=JSX_CLOSE, value=">", start=23, end=24), 9 | Token(type=JSX_SLASH_OPEN, value="", start=29, end=30), 12 | Token(type=JSX_CLOSE, value=">", start=30, end=31), 13 | Token(type=JSX_SLASH_OPEN, value="", start=42, end=43), 16 | ] 17 | -------------------------------------------------------------------------------- /tests/data/tokenizer-element-attributes-6.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="Component", start=1, end=10), 4 | Token(type=ATTRIBUTE, value="wrapper", start=11, end=18), 5 | Token(type=OP, value="=", start=18, end=19), 6 | Token(type=JSX_OPEN, value="<", start=19, end=20), 7 | Token(type=ELEMENT_NAME, value="div", start=20, end=23), 8 | Token(type=JSX_CLOSE, value=">", start=23, end=24), 9 | Token(type=JSX_SLASH_OPEN, value="", start=29, end=30), 12 | Token(type=JSX_CLOSE, value=">", start=31, end=32), 13 | Token(type=JSX_SLASH_OPEN, value="", start=43, end=44), 16 | ] 17 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Have you found a bug or do you have an idea for a new feature? Feel free to open 4 | an issue and/or submit a PR! 5 | 6 | ## Developing 7 | 8 | To contribute to this project, a development environment is recommended. You'll 9 | need Python 3.10+ and ideally [uv](https://docs.astral.sh/uv/) installed. 10 | 11 | ### Installing the project: 12 | 13 | ```sh 14 | uv sync 15 | ``` 16 | 17 | ## Running tests and linters 18 | 19 | ### Tests 20 | 21 | To execute the tests, run: 22 | 23 | ```sh 24 | uv run pytest 25 | # Or with the venv activated: 26 | pytest 27 | ``` 28 | 29 | ### Linting 30 | 31 | This project uses ruff, you can run it as: 32 | 33 | ```sh 34 | uv run ruff check 35 | # Or with the venv activated: 36 | ruff check 37 | ``` 38 | 39 | ### Type checking 40 | 41 | You can also check your code with ty/mypy: 42 | 43 | ```sh 44 | uv run ty check 45 | uv run mypy 46 | # Or with the venv activated: 47 | ty check 48 | mypy 49 | ``` 50 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | name: Build and Test (${{ matrix.python-version }}) 14 | runs-on: ubuntu-latest 15 | strategy: 16 | matrix: 17 | python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"] 18 | 19 | steps: 20 | - uses: actions/checkout@v6 21 | 22 | - name: Install the latest version of uv and set the python version 23 | uses: astral-sh/setup-uv@v6 24 | with: 25 | python-version: ${{ matrix.python-version }} 26 | 27 | - name: Lint code 🧹 28 | run: | 29 | uv run ruff check 30 | 31 | - name: Type check with ty 🏷 32 | run: | 33 | uv run ty check 34 | 35 | - name: Type chec with mypy 🏷 36 | run: | 37 | uv run mypy 38 | 39 | - name: Run tests 🧪 40 | run: | 41 | uv run pytest --no-cov 42 | -------------------------------------------------------------------------------- /tests/test_examples.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | import sys 3 | from pathlib import Path 4 | 5 | import pytest 6 | 7 | 8 | def run_example(name: str) -> str: 9 | path = Path(__file__).parents[1] / "examples" / name / "main.py" 10 | return subprocess.run( # noqa: S603 11 | [sys.executable, str(path)], text=True, check=True, capture_output=True 12 | ).stdout 13 | 14 | 15 | @pytest.mark.parametrize( 16 | ("example", "loader"), 17 | [ 18 | ("table", "codec"), 19 | ("table", "import_hook"), 20 | ("props", "codec"), 21 | ("props", "import_hook"), 22 | ("custom_components", "codec"), 23 | ("custom_components", "import_hook"), 24 | ("custom_elements", "codec"), 25 | ("custom_elements", "import_hook"), 26 | ], 27 | ) 28 | def test_example(snapshot, example: str, loader: str): 29 | snapshot.snapshot_dir = Path(__file__).parent / "data" 30 | snapshot.assert_match(run_example(f"{example}_{loader}"), f"examples-{example}.txt") 31 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## Unreleased 4 | 5 | - Nothing yet ;) 6 | 7 | ## 0.4.0 8 | 9 | - Proper HTML escaping ([#16](https://github.com/tomasr8/pyjsx/pull/16), thanks @leontrolski) 10 | - New class `HTMLDontEscape` to turn off HTML escaping for safe input ([#16](https://github.com/tomasr8/pyjsx/pull/16), thanks @leontrolski) 11 | 12 | ## 0.3.0 13 | 14 | - Mypy plugin ([#8](https://github.com/tomasr8/pyjsx/pull/8), thanks @wbadart) 15 | - Support for custom tags ([#11](https://github.com/tomasr8/pyjsx/pull/11), thanks @mplemay) 16 | 17 | ## 0.2.0 18 | 19 | Officially adds support for an import hook mechanism. This allows you to create .px files containing JSX which can be imported as if they were regular Python files. 20 | 21 | Note that this is in addition to the # coding: jsx mechanism - you can use either. See the examples in the readme for more details. 22 | 23 | Using a separate file extension for pyjsx will make it easier to add IDE support later on. 24 | 25 | ## 0.1.0 26 | 27 | - Initial release 28 | -------------------------------------------------------------------------------- /tests/test_import_hook.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | import pytest 4 | 5 | from pyjsx.import_hook import PyJSXFinder, register_import_hook, unregister_import_hook 6 | 7 | 8 | @pytest.fixture 9 | def import_hook(): 10 | register_import_hook() 11 | yield 12 | unregister_import_hook() 13 | 14 | 15 | def test_finder(): 16 | finder = PyJSXFinder() 17 | path = str(Path(__file__).parent / "test_module") 18 | spec = finder.find_spec("main", [path]) 19 | assert spec is not None 20 | assert spec.name == "main" 21 | 22 | 23 | @pytest.mark.usefixtures("import_hook") 24 | def test_import(): 25 | from .test_module import main # type: ignore[attr-defined] 26 | 27 | assert ( 28 | str(main.hello()) 29 | == """\ 30 |

31 | Hello, World! 32 |

""" 33 | ) 34 | 35 | 36 | @pytest.mark.usefixtures("import_hook") 37 | def test_import_not_found(): 38 | with pytest.raises(ModuleNotFoundError): 39 | from .foo import main # type: ignore[import-untyped] # noqa:F401 40 | -------------------------------------------------------------------------------- /tests/data/tokenizer-mixed-3.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="span", start=1, end=5), 4 | Token(type=JSX_OPEN_BRACE, value="{", start=6, end=7), 5 | Token(type=JSX_SPREAD, value="...", start=7, end=10), 6 | Token(type=OP, value="{", start=10, end=11), 7 | Token(type=SINGLE_LINE_STRING, value="'key'", start=11, end=16), 8 | Token(type=OP, value=":", start=16, end=17), 9 | Token(type=WS, value=" ", start=17, end=18), 10 | Token(type=SINGLE_LINE_STRING, value="'value'", start=18, end=25), 11 | Token(type=OP, value="}", start=25, end=26), 12 | Token(type=JSX_CLOSE_BRACE, value="}", start=26, end=27), 13 | Token(type=JSX_CLOSE, value=">", start=27, end=28), 14 | Token(type=JSX_OPEN_BRACE, value="{", start=28, end=29), 15 | Token(type=NAME, value="title", start=29, end=34), 16 | Token(type=JSX_CLOSE_BRACE, value="}", start=34, end=35), 17 | Token(type=JSX_TEXT, value=" /", start=35, end=37), 18 | Token(type=JSX_CLOSE, value=">", start=37, end=38), 19 | ] 20 | -------------------------------------------------------------------------------- /pyjsx/codec_hook.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import codecs 4 | import encodings 5 | from typing import TYPE_CHECKING 6 | 7 | 8 | if TYPE_CHECKING: 9 | from _typeshed import ReadableBuffer 10 | 11 | from pyjsx.transpiler import transpile 12 | 13 | 14 | def pyjsx_decode(input: ReadableBuffer, errors: str = "strict") -> tuple[str, int]: # noqa: A002, ARG001 15 | byte_content = bytes(input) 16 | return transpile(byte_content.decode("utf-8")), len(byte_content) 17 | 18 | 19 | def pyjsx_search_function(encoding: str) -> codecs.CodecInfo | None: 20 | if encoding != "jsx": 21 | return None 22 | 23 | utf8 = encodings.search_function("utf8") 24 | assert utf8 is not None 25 | return codecs.CodecInfo( 26 | name="jsx", 27 | encode=utf8.encode, 28 | decode=pyjsx_decode, 29 | incrementalencoder=utf8.incrementalencoder, 30 | incrementaldecoder=utf8.incrementaldecoder, 31 | streamreader=utf8.streamreader, 32 | streamwriter=utf8.streamwriter, 33 | ) 34 | 35 | 36 | def register_jsx() -> None: 37 | codecs.register(pyjsx_search_function) 38 | -------------------------------------------------------------------------------- /examples/props_import_hook/props.px: -------------------------------------------------------------------------------- 1 | from pyjsx import jsx, JSX 2 | 3 | 4 | def Card(rounded=False, raised=False, image=None, children=None, **rest) -> JSX: 5 | style = { 6 | "border-radius": "5px" if rounded else 0, 7 | "box-shadow": "0 2px 4px rgba(0, 0, 0, 0.1)" if raised else "none", 8 | } 9 | return ( 10 |
11 | {image} 12 | {children} 13 |
14 | ) 15 | 16 | 17 | def Image(src, alt, **rest) -> JSX: 18 | return {alt} 19 | 20 | 21 | def App() -> JSX: 22 | return ( 23 |
24 | }> 25 |

Card title

26 |

Card content

27 |
28 | }> 29 |

Card title

30 |

Card content

31 |
32 | 33 |

Card title

34 |

Card content

35 |
36 |
37 | ) 38 | -------------------------------------------------------------------------------- /examples/props_codec/props.py: -------------------------------------------------------------------------------- 1 | # coding: jsx 2 | from pyjsx import jsx, JSX 3 | 4 | 5 | def Card(rounded=False, raised=False, image=None, children=None, **rest) -> JSX: 6 | style = { 7 | "border-radius": "5px" if rounded else 0, 8 | "box-shadow": "0 2px 4px rgba(0, 0, 0, 0.1)" if raised else "none", 9 | } 10 | return ( 11 |
12 | {image} 13 | {children} 14 |
15 | ) 16 | 17 | 18 | def Image(src, alt, **rest) -> JSX: 19 | return {alt} 20 | 21 | 22 | def App() -> JSX: 23 | return ( 24 |
25 | }> 26 |

Card title

27 |

Card content

28 |
29 | }> 30 |

Card title

31 |

Card content

32 |
33 | 34 |

Card title

35 |

Card content

36 |
37 |
38 | ) 39 | -------------------------------------------------------------------------------- /plugin-vscode/README.md: -------------------------------------------------------------------------------- 1 | # PyJSX Language Support 2 | 3 | ![](example.png) 4 | 5 | This plugin provides basic language support for [PyJSX](https://github.com/tomasr8/pyjsx). 6 | 7 | This includes: 8 | - __Syntax highlighting__ 9 | - __Ability to toggle comments__ (`Ctrl + /` by default) 10 | 11 | ## Extension Settings 12 | 13 | There are currenly no settings available. 14 | 15 | ## Release Notes 16 | 17 | ### 0.0.1 18 | 19 | * Initial release 20 | 21 | ## Technical details 22 | 23 | The TextMate grammar is a combination of the official 24 | [Python](https://github.com/microsoft/vscode/tree/main/extensions/python) and 25 | [JS](https://github.com/microsoft/vscode/tree/main/extensions/javascript) 26 | grammars. 27 | 28 | I use the Python grammar with just a few modifications. The JSX grammar is added by extending the `expression` rule: 29 | 30 | ```json 31 | "expression": { 32 | "comment": "All valid Python expressions", 33 | "patterns": [ 34 | { 35 | "include": "#jsx" 36 | }, 37 | ... more patterns 38 | ] 39 | }, 40 | ``` 41 | 42 | The JSX grammar is defined as an [embedded grammar](https://code.visualstudio.com/api/language-extensions/syntax-highlight-guide#embedded-languages). 43 | 44 | -------------------------------------------------------------------------------- /pyjsx/util.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Generator, Iterable 2 | from typing import TypeVar 3 | 4 | 5 | T = TypeVar("T") 6 | 7 | 8 | def indent(text: str, spaces: int = 4) -> str: 9 | return "\n".join(f"{' ' * spaces}{line}" for line in text.split("\n")) 10 | 11 | 12 | def flatten(children: Iterable[T]) -> Generator[T]: 13 | for child in children: 14 | if isinstance(child, list | tuple): 15 | yield from flatten(child) 16 | else: 17 | yield child 18 | 19 | 20 | def get_line_number_offset(source: str, start: int) -> tuple[int, int]: 21 | line_start = start 22 | 23 | while line_start > 0 and source[line_start - 1] != "\n": 24 | line_start -= 1 25 | 26 | line_number = 0 27 | offset = start 28 | while offset > 0: 29 | if source[offset] == "\n": 30 | line_number += 1 31 | offset -= 1 32 | 33 | return line_number + 1, start - line_start 34 | 35 | 36 | def highlight_line(source: str, start: int, end: int) -> str: 37 | line_start = start 38 | line_end = end 39 | 40 | while line_start > 0 and source[line_start - 1] != "\n": 41 | line_start -= 1 42 | while line_end < len(source) and source[line_end] != "\n": 43 | line_end += 1 44 | 45 | offset = start - line_start 46 | highlight = offset * " " + (end - start) * "^" 47 | 48 | return f"{source[line_start:line_end]}\n{highlight}" 49 | -------------------------------------------------------------------------------- /tests/data/tokenizer-nesting-1.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="div", start=1, end=4), 4 | Token(type=JSX_CLOSE, value=">", start=4, end=5), 5 | Token(type=JSX_TEXT, value="\n Click ", start=5, end=16), 6 | Token(type=JSX_OPEN, value="<", start=16, end=17), 7 | Token(type=ELEMENT_NAME, value="a", start=17, end=18), 8 | Token(type=JSX_CLOSE, value=">", start=18, end=19), 9 | Token(type=JSX_TEXT, value="here", start=19, end=23), 10 | Token(type=JSX_SLASH_OPEN, value="", start=26, end=27), 13 | Token(type=JSX_TEXT, value="\n or\n ", start=27, end=39), 14 | Token(type=JSX_OPEN, value="<", start=39, end=40), 15 | Token(type=ELEMENT_NAME, value="button", start=40, end=46), 16 | Token(type=JSX_CLOSE, value=">", start=46, end=47), 17 | Token(type=JSX_TEXT, value="Submit", start=47, end=53), 18 | Token(type=JSX_SLASH_OPEN, value="", start=61, end=62), 21 | Token(type=JSX_TEXT, value="\n", start=62, end=63), 22 | Token(type=JSX_SLASH_OPEN, value="", start=68, end=69), 25 | ] 26 | -------------------------------------------------------------------------------- /plugin-vscode/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pyjsx", 3 | "displayName": "PyJSX", 4 | "description": "Provides Language Support for PyJSX", 5 | "publisher": "tomasr8", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/tomasr8/pyjsx" 9 | }, 10 | "version": "0.0.1", 11 | "engines": { 12 | "vscode": "^1.96.0" 13 | }, 14 | "categories": [ 15 | "Programming Languages" 16 | ], 17 | "contributes": { 18 | "languages": [ 19 | { 20 | "id": "pyjsx", 21 | "aliases": [ 22 | "Python with JSX", 23 | "pyjsx" 24 | ], 25 | "extensions": [ 26 | ".px", 27 | ".pyjsx" 28 | ], 29 | "configuration": "./language-configuration.json" 30 | }, 31 | { 32 | "id": "python-jsx-tags", 33 | "aliases": [], 34 | "configuration": "./tags-language-configuration.json" 35 | } 36 | ], 37 | "grammars": [ 38 | { 39 | "language": "pyjsx", 40 | "scopeName": "source.pyjsx", 41 | "path": "./syntaxes/pyjsx.tmLanguage.json", 42 | "embeddedLanguages": { 43 | "meta.tag.pyjsx": "python-jsx-tags", 44 | "meta.tag.without-attributes.pyjsx": "python-jsx-tags", 45 | "meta.tag.attributes.pyjsx.jsx": "pyjsx", 46 | "meta.embedded.expression.pyjsx": "pyjsx" 47 | } 48 | }, 49 | { 50 | "scopeName": "source.regexp.pyjsx", 51 | "path": "./syntaxes/MagicRegExp.tmLanguage.json" 52 | } 53 | ] 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /plugin-vscode/vsc-extension-quickstart.md: -------------------------------------------------------------------------------- 1 | # Welcome to your VS Code Extension 2 | 3 | ## What's in the folder 4 | 5 | * This folder contains all of the files necessary for your extension. 6 | * `package.json` - this is the manifest file in which you declare your language support and define the location of the grammar file that has been copied into your extension. 7 | * `syntaxes/pyjsx.tmLanguage.json` - this is the Text mate grammar file that is used for tokenization. 8 | * `language-configuration.json` - this is the language configuration, defining the tokens that are used for comments and brackets. 9 | 10 | ## Get up and running straight away 11 | 12 | * Make sure the language configuration settings in `language-configuration.json` are accurate. 13 | * Press `F5` to open a new window with your extension loaded. 14 | * Create a new file with a file name suffix matching your language. 15 | * Verify that syntax highlighting works and that the language configuration settings are working. 16 | 17 | ## Make changes 18 | 19 | * You can relaunch the extension from the debug toolbar after making changes to the files listed above. 20 | * You can also reload (`Ctrl+R` or `Cmd+R` on Mac) the VS Code window with your extension to load your changes. 21 | 22 | ## Add more language features 23 | 24 | * To add features such as IntelliSense, hovers and validators check out the VS Code extenders documentation at https://code.visualstudio.com/docs 25 | 26 | ## Install your extension 27 | 28 | * To start using your extension with Visual Studio Code copy it into the `/.vscode/extensions` folder and restart Code. 29 | * To share your extension with the world, read on https://code.visualstudio.com/docs about publishing an extension. 30 | -------------------------------------------------------------------------------- /tests/test_runtime.py: -------------------------------------------------------------------------------- 1 | from typing import Any 2 | 3 | import pytest 4 | 5 | from pyjsx import JSX, JSXComponent, jsx, transpile 6 | 7 | 8 | def run_example(source: str, _locals: dict[str, Any] | None = None) -> str: 9 | py_code = transpile(source) 10 | out = eval(py_code, {"jsx": jsx}, _locals) # noqa: S307 11 | assert isinstance(out, str) 12 | return out 13 | 14 | 15 | def test_passing_jsx_as_props(): 16 | def CardWithImageComponent(image: JSX | None = None, **_) -> JSX: 17 | return jsx("div", {}, [image]) 18 | 19 | def CardWithImageCallable(image: JSXComponent, **_) -> JSX: 20 | return jsx("div", {}, [jsx(image, {}, [])]) 21 | 22 | def Image(src: str = "example.jpg", alt: str | None = None, **_) -> JSX: 23 | return jsx("img", {"src": src, "alt": alt}, []) 24 | 25 | html = run_example("str()", {"Card": CardWithImageComponent, "Image": Image}) 26 | assert html == "
" 27 | 28 | with pytest.raises(TypeError): 29 | run_example("str()", {"Card": CardWithImageCallable, "Image": Image}) 30 | 31 | html = run_example( 32 | "str(} />)", 33 | {"Card": CardWithImageComponent, "Image": Image}, 34 | ) 35 | assert ( 36 | html 37 | == """\ 38 |
39 | 40 |
""" 41 | ) 42 | 43 | html = run_example("str()", {"Card": CardWithImageCallable, "Image": Image}) 44 | assert ( 45 | html 46 | == """\ 47 |
48 | 49 |
""" 50 | ) 51 | 52 | with pytest.raises(TypeError) as excinfo: 53 | run_example( 54 | "str(} />)", 55 | {"Card": CardWithImageCallable, "Image": Image}, 56 | ) 57 | assert str(excinfo.value) == "Element type is invalid. Expected a string or a function but got: " 58 | -------------------------------------------------------------------------------- /tests/data/tokenizer-mixed-4.txt: -------------------------------------------------------------------------------- 1 | [ 2 | Token(type=JSX_OPEN, value="<", start=0, end=1), 3 | Token(type=ELEMENT_NAME, value="span", start=1, end=5), 4 | Token(type=JSX_CLOSE, value=">", start=5, end=6), 5 | Token(type=JSX_OPEN_BRACE, value="{", start=6, end=7), 6 | Token(type=JSX_OPEN, value="<", start=7, end=8), 7 | Token(type=ELEMENT_NAME, value="h1", start=8, end=10), 8 | Token(type=JSX_CLOSE, value=">", start=10, end=11), 9 | Token(type=JSX_OPEN_BRACE, value="{", start=11, end=12), 10 | Token(type=NAME, value="title", start=12, end=17), 11 | Token(type=JSX_CLOSE_BRACE, value="}", start=17, end=18), 12 | Token(type=JSX_SLASH_OPEN, value="", start=22, end=23), 15 | Token(type=WS, value=" ", start=23, end=24), 16 | Token(type=NAME, value="if", start=24, end=26), 17 | Token(type=WS, value=" ", start=26, end=27), 18 | Token(type=NAME, value="cond", start=27, end=31), 19 | Token(type=WS, value=" ", start=31, end=32), 20 | Token(type=ANY, value="else", start=32, end=36), 21 | Token(type=WS, value=" ", start=36, end=37), 22 | Token(type=JSX_OPEN, value="<", start=37, end=38), 23 | Token(type=ELEMENT_NAME, value="h2", start=38, end=40), 24 | Token(type=JSX_CLOSE, value=">", start=40, end=41), 25 | Token(type=JSX_OPEN_BRACE, value="{", start=41, end=42), 26 | Token(type=NAME, value="title", start=42, end=47), 27 | Token(type=JSX_CLOSE_BRACE, value="}", start=47, end=48), 28 | Token(type=JSX_SLASH_OPEN, value="", start=52, end=53), 31 | Token(type=JSX_CLOSE_BRACE, value="}", start=53, end=54), 32 | Token(type=JSX_SLASH_OPEN, value="", start=60, end=61), 35 | ] 36 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "python-jsx" 3 | authors = [{ name = "Tomas Roun", email = "tomas.roun8@gmail.com" }] 4 | description = "JSX transpiler for Python" 5 | readme = "README.md" 6 | requires-python = ">=3.10" 7 | keywords = ["JSX", "React", "transpiler", "template-engine"] 8 | license = "Apache-2.0" 9 | classifiers = [ 10 | "Operating System :: OS Independent", 11 | "Programming Language :: Python :: 3", 12 | "Programming Language :: Python :: 3.10", 13 | "Programming Language :: Python :: 3.11", 14 | "Programming Language :: Python :: 3.12", 15 | "Programming Language :: Python :: 3.13", 16 | "Programming Language :: Python :: 3.14", 17 | ] 18 | dependencies = [] 19 | dynamic = ["version"] 20 | 21 | [project.urls] 22 | Homepage = "https://github.com/tomasr8/pyjsx" 23 | GitHub = "https://github.com/tomasr8/pyjsx" 24 | 25 | [tool.pytest.ini_options] 26 | testpaths = ["tests/"] 27 | 28 | [tool.ruff] 29 | target-version = "py310" 30 | line-length = 120 31 | exclude = ["examples"] 32 | 33 | [tool.ruff.lint] 34 | select = ["ALL"] 35 | ignore = [ 36 | "D", 37 | "ANN002", 38 | "ANN003", 39 | "ANN204", 40 | "ANN401", 41 | "COM812", 42 | "BLE001", 43 | "ERA001", 44 | "RET503", 45 | "ISC001", 46 | "N802", 47 | "N803", 48 | "N806", 49 | "N818", 50 | "S101", 51 | "PLC0415", 52 | ] 53 | 54 | [tool.ruff.lint.per-file-ignores] 55 | "tests/*" = ["ANN001", "ANN201", "ANN202", "PLR2004"] 56 | "examples/*" = ["I001", "F401"] 57 | 58 | [tool.ruff.lint.isort] 59 | lines-after-imports = 2 60 | 61 | [tool.ty.src] 62 | exclude = ["examples"] 63 | 64 | [tool.mypy] 65 | packages = ["pyjsx", "tests"] 66 | strict = true 67 | 68 | [[tool.mypy.overrides]] 69 | module = "tests.*" 70 | disallow_untyped_defs = false 71 | disallow_incomplete_defs = false 72 | 73 | [tool.hatch.version] 74 | path = "pyjsx/__init__.py" 75 | 76 | [tool.hatch.build.targets.wheel] 77 | packages = ["pyjsx"] 78 | 79 | [build-system] 80 | requires = ["hatchling==1.28.0"] 81 | build-backend = "hatchling.build" 82 | 83 | [dependency-groups] 84 | dev = [ 85 | "pytest==9.0.2", 86 | "pytest-snapshot==0.9.0", 87 | "pytest-cov==7.0.0", 88 | "ruff==0.14.8", 89 | "ty==0.0.1a32", 90 | "mypy==1.19.0", 91 | ] 92 | -------------------------------------------------------------------------------- /plugin-vscode/language-configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "comments": { 3 | "lineComment": "#", 4 | "blockComment": ["\"\"\"", "\"\"\""] 5 | }, 6 | "brackets": [ 7 | ["{", "}"], 8 | ["[", "]"], 9 | ["(", ")"] 10 | ], 11 | "autoClosingPairs": [ 12 | { "open": "{", "close": "}" }, 13 | { "open": "[", "close": "]" }, 14 | { "open": "(", "close": ")" }, 15 | { "open": "\"", "close": "\"", "notIn": ["string"] }, 16 | { "open": "r\"", "close": "\"", "notIn": ["string", "comment"] }, 17 | { "open": "R\"", "close": "\"", "notIn": ["string", "comment"] }, 18 | { "open": "u\"", "close": "\"", "notIn": ["string", "comment"] }, 19 | { "open": "U\"", "close": "\"", "notIn": ["string", "comment"] }, 20 | { "open": "f\"", "close": "\"", "notIn": ["string", "comment"] }, 21 | { "open": "F\"", "close": "\"", "notIn": ["string", "comment"] }, 22 | { "open": "b\"", "close": "\"", "notIn": ["string", "comment"] }, 23 | { "open": "B\"", "close": "\"", "notIn": ["string", "comment"] }, 24 | { "open": "'", "close": "'", "notIn": ["string", "comment"] }, 25 | { "open": "r'", "close": "'", "notIn": ["string", "comment"] }, 26 | { "open": "R'", "close": "'", "notIn": ["string", "comment"] }, 27 | { "open": "u'", "close": "'", "notIn": ["string", "comment"] }, 28 | { "open": "U'", "close": "'", "notIn": ["string", "comment"] }, 29 | { "open": "f'", "close": "'", "notIn": ["string", "comment"] }, 30 | { "open": "F'", "close": "'", "notIn": ["string", "comment"] }, 31 | { "open": "b'", "close": "'", "notIn": ["string", "comment"] }, 32 | { "open": "B'", "close": "'", "notIn": ["string", "comment"] }, 33 | { "open": "`", "close": "`", "notIn": ["string"] } 34 | ], 35 | "surroundingPairs": [ 36 | ["{", "}"], 37 | ["[", "]"], 38 | ["(", ")"], 39 | ["\"", "\""], 40 | ["'", "'"], 41 | ["`", "`"] 42 | ], 43 | "folding": { 44 | "offSide": true, 45 | "markers": { 46 | "start": "^\\s*#\\s*region\\b", 47 | "end": "^\\s*#\\s*endregion\\b" 48 | } 49 | }, 50 | "onEnterRules": [ 51 | { 52 | "beforeText": "^\\s*(?:def|class|for|if|elif|else|while|try|with|finally|except|async).*?:\\s*$", 53 | "action": { "indent": "indent" } 54 | } 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /pyjsx/import_hook.py: -------------------------------------------------------------------------------- 1 | # Inspired by https://github.com/pyxy-org/pyxy/blob/main/pyxy/importer/hook.py 2 | # Allow importing JSX as Python modules 3 | # This is in addition to the coding hook which transpiles .py files 4 | # This allows one to write JSX code in .px files and import them as Python modules. 5 | # 6 | # For example: 7 | # # hello.px 8 | # def hello(): 9 | # return
Hello, World!
10 | # 11 | # # main.py 12 | # import hello 13 | # print(hello.hello()) 14 | 15 | import importlib.util 16 | import sys 17 | from collections.abc import Sequence 18 | from importlib.abc import FileLoader, MetaPathFinder 19 | from importlib.machinery import ModuleSpec 20 | from pathlib import Path 21 | from types import ModuleType 22 | 23 | from pyjsx.transpiler import transpile 24 | 25 | 26 | PYJSX_SUFFIX = ".px" 27 | 28 | 29 | class PyJSXLoader(FileLoader): 30 | def _compile(self) -> str: 31 | return transpile(Path(self.path).read_text("utf-8")) 32 | 33 | def exec_module(self, module: ModuleType) -> None: 34 | code = self._compile() 35 | exec(code, module.__dict__) # noqa: S102 36 | 37 | def get_source(self, fullname: str) -> str: # noqa: ARG002 38 | return self._compile() 39 | 40 | 41 | class PyJSXFinder(MetaPathFinder): 42 | def find_spec( 43 | self, 44 | fullname: str, 45 | path: Sequence[str] | None, 46 | target: ModuleType | None = None, # noqa: ARG002 47 | ) -> ModuleSpec | None: 48 | if not path: 49 | path = sys.path 50 | 51 | for p in path: 52 | if spec := self._spec_from_path(fullname, p): 53 | return spec 54 | 55 | return None 56 | 57 | def _spec_from_path(self, fullname: str, path: str) -> ModuleSpec | None: 58 | last_segment = fullname.rsplit(".", maxsplit=1)[-1] 59 | full_path = Path(path) / f"{last_segment}{PYJSX_SUFFIX}" 60 | if full_path.exists(): 61 | loader = PyJSXLoader(fullname, str(full_path)) 62 | return importlib.util.spec_from_loader(fullname, loader) 63 | return None 64 | 65 | 66 | def register_import_hook() -> None: 67 | """Register import hook for .px files.""" 68 | sys.meta_path.append(PyJSXFinder()) 69 | 70 | 71 | def unregister_import_hook() -> None: 72 | """Unregister import hook for .px files.""" 73 | sys.meta_path = [finder for finder in sys.meta_path if not isinstance(finder, PyJSXFinder)] 74 | -------------------------------------------------------------------------------- /pyjsx/elements.py: -------------------------------------------------------------------------------- 1 | void_elements = { 2 | "area", 3 | "base", 4 | "br", 5 | "col", 6 | "embed", 7 | "hr", 8 | "img", 9 | "input", 10 | "link", 11 | "meta", 12 | "param", 13 | "source", 14 | "track", 15 | "wbr", 16 | } 17 | 18 | builtin_elements = { 19 | "a", 20 | "abbr", 21 | "address", 22 | "area", 23 | "article", 24 | "aside", 25 | "audio", 26 | "b", 27 | "base", 28 | "bdi", 29 | "bdo", 30 | "blockquote", 31 | "body", 32 | "br", 33 | "button", 34 | "canvas", 35 | "caption", 36 | "cite", 37 | "code", 38 | "col", 39 | "colgroup", 40 | "data", 41 | "datalist", 42 | "dd", 43 | "del", 44 | "details", 45 | "dfn", 46 | "dialog", 47 | "div", 48 | "dl", 49 | "dt", 50 | "em", 51 | "embed", 52 | "fieldset", 53 | "figcaption", 54 | "figure", 55 | "footer", 56 | "form", 57 | "h1", 58 | "h2", 59 | "h3", 60 | "h4", 61 | "h5", 62 | "h6", 63 | "head", 64 | "header", 65 | "hgroup", 66 | "hr", 67 | "html", 68 | "i", 69 | "iframe", 70 | "img", 71 | "input", 72 | "ins", 73 | "kbd", 74 | "label", 75 | "legend", 76 | "li", 77 | "link", 78 | "main", 79 | "map", 80 | "mark", 81 | "math", 82 | "menu", 83 | "menuitem", 84 | "meta", 85 | "meter", 86 | "nav", 87 | "noscript", 88 | "object", 89 | "ol", 90 | "optgroup", 91 | "option", 92 | "output", 93 | "p", 94 | "param", 95 | "picture", 96 | "pre", 97 | "progress", 98 | "q", 99 | "rb", 100 | "rp", 101 | "rt", 102 | "rtc", 103 | "ruby", 104 | "s", 105 | "samp", 106 | "script", 107 | "search", 108 | "section", 109 | "select", 110 | "slot", 111 | "small", 112 | "source", 113 | "span", 114 | "strong", 115 | "style", 116 | "sub", 117 | "summary", 118 | "sup", 119 | "svg", 120 | "table", 121 | "tbody", 122 | "td", 123 | "template", 124 | "textarea", 125 | "tfoot", 126 | "th", 127 | "thead", 128 | "time", 129 | "title", 130 | "tr", 131 | "track", 132 | "u", 133 | "ul", 134 | "var", 135 | "video", 136 | "wbr", 137 | } 138 | 139 | 140 | def is_void_element(tag: str) -> bool: 141 | return tag in void_elements 142 | 143 | 144 | def is_builtin_element(tag: str) -> bool: 145 | return tag in builtin_elements or "-" in tag 146 | -------------------------------------------------------------------------------- /tests/test_jsx.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from pyjsx.jsx import JSX, jsx 4 | 5 | 6 | @pytest.mark.parametrize( 7 | ("source", "expected"), 8 | [ 9 | (jsx(jsx.Fragment, {}, []), ""), 10 | (jsx(jsx.Fragment, {}, ["test"]), "test"), 11 | (jsx(jsx.Fragment, {}, ["1st line", "2nd line"]), "1st line\n2nd line"), 12 | ( 13 | jsx(jsx.Fragment, {}, ["first", jsx("b", {}, ["bold"]), "last"]), 14 | """\ 15 | first 16 | 17 | bold 18 | 19 | last""", 20 | ), 21 | ], 22 | ) 23 | def test_fragments(source: str, expected: str): 24 | assert str(source) == expected 25 | 26 | 27 | @pytest.mark.parametrize( 28 | ("source", "expected"), 29 | [ 30 | (jsx("div", {}, []), "
"), 31 | (jsx("div", {"foo": "bar"}, []), '
'), 32 | (jsx("input", {}, []), ""), 33 | ( 34 | jsx("input", {"type": "number", "disabled": True}, []), 35 | '', 36 | ), 37 | ( 38 | jsx( 39 | "span", 40 | {"style": {"font-size": "14px", "font-weight": "bold"}}, 41 | ["text"], 42 | ), 43 | """\ 44 | 45 | text 46 | """, 47 | ), 48 | ( 49 | jsx("ul", {}, [jsx("li", {}, ["item 1"]), jsx("li", {}, ["item 2"])]), 50 | """\ 51 |
    52 |
  • 53 | item 1 54 |
  • 55 |
  • 56 | item 2 57 |
  • 58 |
""", 59 | ), 60 | ], 61 | ) 62 | def test_builtins(source: str, expected: str): 63 | assert str(source) == expected 64 | 65 | 66 | def test_custom_components(): 67 | def Component(children: list[JSX], **_) -> JSX: 68 | return jsx("div", {"class": "wrapper"}, children) 69 | 70 | source = jsx(Component, {}, ["Hello, world!"]) 71 | assert ( 72 | str(source) 73 | == """\ 74 |
75 | Hello, world! 76 |
""" 77 | ) 78 | 79 | 80 | @pytest.mark.parametrize( 81 | ("source", "expected"), 82 | [ 83 | ( 84 | jsx("input", {"foo": '"should escape"'}, []), 85 | '', 86 | ), 87 | ], 88 | ) 89 | def test_attribute_escapes(source: str, expected: str): 90 | assert str(source) == expected 91 | 92 | 93 | @pytest.mark.parametrize( 94 | ("source", "expected"), 95 | [ 96 | ( 97 | jsx("turbo-frame", {"id": "messages"}, []), 98 | '', 99 | ), 100 | ], 101 | ) 102 | def test_custom_elements_rendering(source: str, expected: str): 103 | assert str(source) == expected 104 | -------------------------------------------------------------------------------- /plugin-vscode/tags-language-configuration.json: -------------------------------------------------------------------------------- 1 | { 2 | "comments": { 3 | "blockComment": [ 4 | "{/*", 5 | "*/}" 6 | ] 7 | }, 8 | "brackets": [ 9 | [ 10 | "{", 11 | "}" 12 | ], 13 | [ 14 | "[", 15 | "]" 16 | ], 17 | [ 18 | "(", 19 | ")" 20 | ], 21 | [ 22 | "<", 23 | ">" 24 | ] 25 | ], 26 | "colorizedBracketPairs": [ 27 | [ 28 | "{", 29 | "}" 30 | ], 31 | [ 32 | "[", 33 | "]" 34 | ], 35 | [ 36 | "(", 37 | ")" 38 | ] 39 | ], 40 | "autoClosingPairs": [ 41 | { 42 | "open": "{", 43 | "close": "}" 44 | }, 45 | { 46 | "open": "[", 47 | "close": "]" 48 | }, 49 | { 50 | "open": "(", 51 | "close": ")" 52 | }, 53 | { 54 | "open": "'", 55 | "close": "'", 56 | "notIn": [ 57 | "string", 58 | "comment" 59 | ] 60 | }, 61 | { 62 | "open": "\"", 63 | "close": "\"", 64 | "notIn": [ 65 | "string" 66 | ] 67 | }, 68 | { 69 | "open": "/**", 70 | "close": " */", 71 | "notIn": [ 72 | "string" 73 | ] 74 | } 75 | ], 76 | "surroundingPairs": [ 77 | [ 78 | "{", 79 | "}" 80 | ], 81 | [ 82 | "[", 83 | "]" 84 | ], 85 | [ 86 | "(", 87 | ")" 88 | ], 89 | [ 90 | "<", 91 | ">" 92 | ], 93 | [ 94 | "'", 95 | "'" 96 | ], 97 | [ 98 | "\"", 99 | "\"" 100 | ] 101 | ], 102 | "wordPattern": { 103 | "pattern": "(-?\\d*\\.\\d\\w*)|([^\\`\\~\\!\\@\\$\\^\\&\\*\\(\\)\\=\\+\\[\\{\\]\\}\\\\\\|\\;\\:'\"\\,\\.\\<\\>\\/\\s]+)" 104 | }, 105 | "onEnterRules": [ 106 | { 107 | "beforeText": { 108 | "pattern": "<(?!(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr))([_:\\w][_:\\w\\-.\\d]*)([^/>]*(?!/)>)[^<]*$", 109 | "flags": "i" 110 | }, 111 | "afterText": { 112 | "pattern": "^<\\/([_:\\w][_:\\w-.\\d]*)\\s*>$", 113 | "flags": "i" 114 | }, 115 | "action": { 116 | "indent": "indentOutdent" 117 | } 118 | }, 119 | { 120 | "beforeText": { 121 | "pattern": "<(?!(?:area|base|br|col|embed|hr|img|input|keygen|link|menuitem|meta|param|source|track|wbr))([_:\\w][_:\\w\\-.\\d]*)([^/>]*(?!/)>)[^<]*$", 122 | "flags": "i" 123 | }, 124 | "action": { 125 | "indent": "indent" 126 | } 127 | }, 128 | { 129 | // `beforeText` only applies to tokens of a given language. Since we are dealing with jsx-tags, 130 | // make sure we apply to the closing `>` of a tag so that mixed language spans 131 | // such as `
` are handled properly. 132 | "beforeText": { 133 | "pattern": "^>$" 134 | }, 135 | "afterText": { 136 | "pattern": "^<\\/([_:\\w][_:\\w-.\\d]*)\\s*>$", 137 | "flags": "i" 138 | }, 139 | "action": { 140 | "indent": "indentOutdent" 141 | } 142 | }, 143 | { 144 | "beforeText": { 145 | "pattern": "^>$" 146 | }, 147 | "action": { 148 | "indent": "indent" 149 | } 150 | } 151 | ], 152 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 | # PyJSX - Write JSX directly in Python 6 | [![PyPI - Version](https://img.shields.io/pypi/v/python-jsx)](https://pypi.org/project/python-jsx/) 7 | 8 | ### 👉 We now have a [VSCode plugin](https://marketplace.visualstudio.com/items?itemName=tomasr8.pyjsx)! 9 | 10 | ```python 11 | from pyjsx import jsx, JSX 12 | 13 | def Header(style, children) -> JSX: 14 | return

{children}

15 | 16 | def Main(children) -> JSX: 17 | return
{children}
18 | 19 | def App() -> JSX: 20 | return ( 21 |
22 |
Hello, world!
23 |
24 |

This was rendered with PyJSX!

25 |
26 |
27 | ) 28 | ``` 29 | 30 | ## Installation 31 | 32 | Get it via pip: 33 | 34 | ```sh 35 | pip install python-jsx 36 | ``` 37 | 38 | ## Minimal example (using the `coding` directive) 39 | 40 | > [!TIP] 41 | > There are more examples available in the [examples folder](examples). 42 | 43 | There are two supported ways to seamlessly integrate JSX into your codebase. 44 | One is by registering a custom codec shown here and the other by using a custom import hook shown [below](#minimal-example-using-an-import-hook). 45 | 46 | ```python 47 | # hello.py 48 | # coding: jsx 49 | from pyjsx import jsx 50 | 51 | def hello(): 52 | print(

Hello, world!

) 53 | ``` 54 | 55 | ```python 56 | # main.py 57 | import pyjsx.auto_setup 58 | 59 | from hello import hello 60 | 61 | hello() 62 | ``` 63 | 64 | ```sh 65 | $ python main.py 66 |

Hello, word!

67 | ``` 68 | 69 | Each file containing JSX must contain two things: 70 | 71 | - `# coding: jsx` directive - This tells Python to let our library parse the 72 | file first. 73 | - `from pyjsx import jsx` import. PyJSX transpiles JSX into `jsx(...)` calls so 74 | it must be in scope. 75 | 76 | To run a file containing JSX, the `jsx` codec must be registered first which can 77 | be done with `import pyjsx.auto_setup`. This must occur before importing 78 | any other file containing JSX. 79 | 80 | ## Minimal example (using an import hook) 81 | 82 | > [!TIP] 83 | > There are more examples available in the [examples folder](examples). 84 | 85 | ```python 86 | # hello.px 87 | from pyjsx import jsx 88 | 89 | def hello(): 90 | print(

Hello, world!

) 91 | ``` 92 | 93 | ```python 94 | # main.py 95 | import pyjsx.auto_setup 96 | 97 | from hello import hello 98 | 99 | hello() 100 | ``` 101 | 102 | ```sh 103 | $ python main.py 104 |

Hello, word!

105 | ``` 106 | 107 | Each file containing JSX must contain two things: 108 | 109 | - The file extension must be `.px` 110 | - `from pyjsx import jsx` import. PyJSX transpiles JSX into `jsx(...)` calls so 111 | it must be in scope. 112 | 113 | To be able to import `.px`, the import hook must be registered first which can 114 | be done with `import pyjsx.auto_setup` (same as for the codec version). This must occur before importing any other file containing JSX. 115 | 116 | ## Supported grammar 117 | 118 | The full [JSX grammar](https://facebook.github.io/jsx/) is supported. 119 | Here are a few examples: 120 | 121 | ### Normal and self-closing tags 122 | 123 | ```python 124 | x =
125 | y = 126 | ``` 127 | 128 | ### Props 129 | 130 | ```python 131 | Click me! 132 |
This is red
133 | Spread operator 134 | ``` 135 | 136 | ### Nested expressions 137 | 138 | ```python 139 |
140 | {[

Row: {i}

for i in range(10)]} 141 |
142 | ``` 143 | 144 | ### Fragments 145 | 146 | ```python 147 | fragment = ( 148 | <> 149 |

1st paragraph

150 |

2nd paragraph

151 | 152 | ) 153 | ``` 154 | 155 | ### Custom components 156 | 157 | A custom component can be any function that takes `**kwargs` and 158 | returns JSX or a plain string. The special prop `children` is a list 159 | containing the element's children. 160 | 161 | ```python 162 | def Header(children, **rest): 163 | return

{children}

164 | 165 | header =
Title
166 | print(header) 167 | ``` 168 | 169 | ### Preventing HTML escaping 170 | 171 | By default, PyJSX escapes all string content to prevent XSS attacks. To render raw HTML, wrap strings with `HTMLDontEscape`: 172 | 173 | ```python 174 | from pyjsx import jsx, HTMLDontEscape 175 | 176 | safe_html = HTMLDontEscape("Bold text") 177 | element =
{safe_html}
178 | print(element) #
Bold text
179 | ``` 180 | 181 | ## VS Code support 182 | 183 | PyJSX comes with a [VS Code plugin](https://marketplace.visualstudio.com/items?itemName=tomasr8.pyjsx) that provides syntax highlighting. 184 | 185 | ## Type checking 186 | 187 | PyJSX includes a plugin that allows mypy to parse files with JSX in them. To 188 | use it, add `pyjsx.mypy` to the `plugins` list in your [mypy configuration 189 | file][mypy]. For example, in `mypy.ini`: 190 | 191 | ```ini 192 | [mypy] 193 | plugins = pyjsx.mypy 194 | ``` 195 | 196 | Or in `pyproject.toml`: 197 | 198 | ```toml 199 | [tool.mypy] 200 | plugins = ["pyjsx.mypy"] 201 | ``` 202 | 203 | [mypy]: https://mypy.readthedocs.io/en/stable/config_file.html 204 | 205 | ## Prior art 206 | 207 | Inspired by [packed](https://github.com/michaeljones/packed) and 208 | [pyxl4](https://github.com/pyxl4/pyxl4). 209 | -------------------------------------------------------------------------------- /pyjsx/jsx.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import html 4 | import re 5 | from typing import Any, Protocol, TypeAlias 6 | 7 | from pyjsx.elements import is_void_element 8 | from pyjsx.util import flatten, indent 9 | 10 | 11 | __all__ = ["jsx"] 12 | 13 | # See https://html.spec.whatwg.org/multipage/syntax.html#attributes-2 14 | # Attribute names must consist of one or more characters other than controls" 15 | _CONTROLS = r"\u0000-\u001F\u007F-\u009F" 16 | # Or U+0020 SPACE, U+0022 ("), U+0027 ('), U+003E (>), U+002F (/), U+003D (=) 17 | _OTHER_INVALID = r"\u0020\u0022\u0027\u003E\u002F\u003D" 18 | # Or noncharacters 19 | _NON_CHARACTERS = ( 20 | r"\uFDD0-\uFDEF" 21 | r"\uFFFE\uFFFF" 22 | r"\U0001FFFE\U0001FFFF" 23 | r"\U0002FFFE\U0002FFFF" 24 | r"\U0003FFFE\U0003FFFF" 25 | r"\U0004FFFE\U0004FFFF" 26 | r"\U0005FFFE\U0005FFFF" 27 | r"\U0006FFFE\U0006FFFF" 28 | r"\U0007FFFE\U0007FFFF" 29 | r"\U0008FFFE\U0008FFFF" 30 | r"\U0009FFFE\U0009FFFF" 31 | r"\U000AFFFE\U000AFFFF" 32 | r"\U000BFFFE\U000BFFFF" 33 | r"\U000CFFFE\U000CFFFF" 34 | r"\U000DFFFE\U000DFFFF" 35 | r"\U000EFFFE\U000EFFFF" 36 | r"\U000FFFFE\U000FFFFF" 37 | r"\U0010FFFE\U0010FFFF" 38 | ) 39 | VALID_KEY_REGEX = re.compile(f"^[^{_CONTROLS}{_OTHER_INVALID}{_NON_CHARACTERS}]+$") 40 | 41 | _Props: TypeAlias = dict[str, Any] 42 | 43 | 44 | class JSXComponent(Protocol): 45 | __name__: str 46 | 47 | def __call__(self, *, children: list[JSX], **rest: Any) -> JSX: ... 48 | 49 | 50 | class JSXFragment(Protocol): 51 | __name__: str 52 | 53 | def __call__(self, *, children: list[JSX], **rest: Any) -> list[JSX]: ... 54 | 55 | 56 | class JSXElement(Protocol): 57 | def __str__(self) -> str: ... 58 | 59 | 60 | class HTMLDontEscape(str): 61 | """A string wrapper that prevents HTML escaping when rendering JSX.""" 62 | __slots__ = () 63 | 64 | 65 | def _escape(value: str) -> str: 66 | if isinstance(value, HTMLDontEscape): 67 | return value 68 | return html.escape(value) 69 | 70 | 71 | def _format_css_rule(key: str, value: Any) -> str: 72 | return f"{key}: {value}" 73 | 74 | 75 | def _preprocess_props(props: _Props) -> _Props: 76 | if (style := props.get("style")) and isinstance(style, dict): 77 | props["style"] = "; ".join(_format_css_rule(k, v) for k, v in style.items() if v is not None) 78 | return props 79 | 80 | 81 | def _render_prop(key: str, value: Any) -> str: 82 | if isinstance(value, bool): 83 | return key if value else "" 84 | value = _escape(str(value)) 85 | return f'{key}="{value}"' 86 | 87 | 88 | def _render_props(props: _Props) -> str: 89 | not_none = {k: v for k, v in props.items() if v is not None and VALID_KEY_REGEX.match(k)} 90 | return " ".join([_render_prop(k, v) for k, v in not_none.items()]) 91 | 92 | 93 | class _JSXElement: 94 | def __init__( 95 | self, 96 | tag: str | JSXComponent | JSXFragment, 97 | props: _Props, 98 | children: list[JSX], 99 | ): 100 | self.tag = tag 101 | self.props = props 102 | self.children = children 103 | 104 | def __repr__(self) -> str: 105 | tag = self.tag if isinstance(self.tag, str) else self.tag.__name__ 106 | return f"<{tag} />" 107 | 108 | def __str__(self) -> str: 109 | return self.render() 110 | 111 | def render(self) -> str: 112 | match self.tag: 113 | case str(): 114 | return self.render_native_element(self.tag) 115 | case _: 116 | return self.render_custom_component(self.tag) 117 | 118 | def render_native_element(self, tag: str) -> str: 119 | """Render a native HTML element such as
, , etc.""" 120 | props = _render_props(self.props) 121 | if props: 122 | props = f" {props}" 123 | children = [child for child in flatten(self.children) if child is not None] 124 | if not children: 125 | if is_void_element(tag): 126 | return f"<{tag}{props} />" 127 | return f"<{tag}{props}>" 128 | children = [_escape(child) if isinstance(child, str) else child for child in children] 129 | children_formatted = "\n".join(indent(str(child)) for child in children) 130 | return f"<{tag}{props}>\n{children_formatted}\n" 131 | 132 | def render_custom_component(self, tag: JSXComponent | JSXFragment) -> str: 133 | """Render a custom component which is a callable that returns JSX.""" 134 | rendered = tag(**self.props, children=self.children) 135 | match rendered: 136 | case tuple() | list(): 137 | return "\n".join(str(child) for child in rendered) 138 | case str(): 139 | return _escape(rendered) 140 | case _: 141 | return str(rendered) 142 | 143 | 144 | class _JSX: 145 | def __call__( 146 | self, 147 | tag: str | JSXComponent | JSXFragment, 148 | props: _Props, 149 | children: list[JSX], 150 | ) -> JSXElement: 151 | if not isinstance(tag, str) and not callable(tag): 152 | msg = f"Element type is invalid. Expected a string or a function but got: {tag!r}" 153 | raise TypeError(msg) 154 | props = _preprocess_props(props) 155 | return _JSXElement(tag, props, children) 156 | 157 | def Fragment(self, *, children: list[JSX], **_: Any) -> list[JSX]: 158 | return children 159 | 160 | 161 | jsx = _JSX() 162 | JSX: TypeAlias = JSXElement | str 163 | -------------------------------------------------------------------------------- /tests/test_tokenizer.py: -------------------------------------------------------------------------------- 1 | import itertools 2 | from pathlib import Path 3 | from subprocess import PIPE, Popen 4 | 5 | import pytest 6 | 7 | from pyjsx.tokenizer import Tokenizer, TokenizerError 8 | 9 | 10 | def ruff_format(source: str) -> str: 11 | p = Popen(["ruff", "format", "-"], shell=False, stdin=PIPE, stdout=PIPE, stderr=PIPE) # noqa: S607 12 | output, err = p.communicate(source.encode("utf-8")) 13 | assert not err 14 | return output.decode("utf-8").replace("\r\n", "\n") 15 | 16 | 17 | @pytest.mark.parametrize( 18 | "source", 19 | [ 20 | "
", 21 | "", 22 | "<>", 23 | "
Hello, world!
", 24 | ], 25 | ids=itertools.count(1), 26 | ) 27 | def test_simple(request, snapshot, source: str): 28 | snapshot.snapshot_dir = Path(__file__).parent / "data" 29 | tokenizer = Tokenizer(source) 30 | tokens = list(tokenizer.tokenize()) 31 | snapshot.assert_match(ruff_format(repr(tokens)), f"tokenizer-simple-{request.node.callspec.id}.txt") 32 | 33 | 34 | @pytest.mark.parametrize( 35 | "source", 36 | [ 37 | "", 38 | "", 39 | ], 40 | ids=itertools.count(1), 41 | ) 42 | def test_element_names(request, snapshot, source: str): 43 | snapshot.snapshot_dir = Path(__file__).parent / "data" 44 | tokenizer = Tokenizer(source) 45 | tokens = list(tokenizer.tokenize()) 46 | snapshot.assert_match(ruff_format(repr(tokens)), f"tokenizer-element-names-{request.node.callspec.id}.txt") 47 | 48 | 49 | @pytest.mark.parametrize( 50 | "source", 51 | [ 52 | "
", 53 | '
', 54 | "", 55 | "", 56 | "
>", 57 | "
>", 58 | ], 59 | ids=itertools.count(1), 60 | ) 61 | def test_attributes_names(request, snapshot, source: str): 62 | snapshot.snapshot_dir = Path(__file__).parent / "data" 63 | tokenizer = Tokenizer(source) 64 | tokens = list(tokenizer.tokenize()) 65 | snapshot.assert_match(ruff_format(repr(tokens)), f"tokenizer-element-attributes-{request.node.callspec.id}.txt") 66 | 67 | 68 | @pytest.mark.parametrize( 69 | "source", 70 | [ 71 | """\ 72 |
73 | Click here 74 | or 75 | 76 |
""", 77 | ], 78 | ids=itertools.count(1), 79 | ) 80 | def test_nesting(request, snapshot, source: str): 81 | snapshot.snapshot_dir = Path(__file__).parent / "data" 82 | tokenizer = Tokenizer(source) 83 | tokens = list(tokenizer.tokenize()) 84 | snapshot.assert_match(ruff_format(repr(tokens)), f"tokenizer-nesting-{request.node.callspec.id}.txt") 85 | 86 | 87 | @pytest.mark.parametrize( 88 | "source", 89 | [ 90 | "{link_title}", 91 | "
{title} />", 92 | "{title} />", 93 | "{

{title}

if cond else

{title}

}
", 94 | ], 95 | ids=itertools.count(1), 96 | ) 97 | def test_mixed(request, snapshot, source: str): 98 | snapshot.snapshot_dir = Path(__file__).parent / "data" 99 | tokenizer = Tokenizer(source) 100 | tokens = list(tokenizer.tokenize()) 101 | snapshot.assert_match(ruff_format(repr(tokens)), f"tokenizer-mixed-{request.node.callspec.id}.txt") 102 | 103 | 104 | @pytest.mark.parametrize( 105 | "source", 106 | [ 107 | """\ 108 | ''' 109 | <>This should not be transpiled 110 | ''' 111 | """, 112 | """\ 113 | ''' 114 |
115 | Neither this 116 | {1+2} 117 |
118 | ''' 119 | """, 120 | '''\ 121 | """ 122 | <>This should not be transpiled 123 | """ 124 | ''', 125 | '''\ 126 | """ 127 |
128 | Neither this 129 | {1+2} 130 |
131 | """ 132 | ''', 133 | '''\ 134 | rB""" 135 |
136 | Neither this 137 | {1+2} 138 |
139 | """ 140 | ''', 141 | ], 142 | ids=itertools.count(1), 143 | ) 144 | def test_multiline_strings(request, snapshot, source: str): 145 | snapshot.snapshot_dir = Path(__file__).parent / "data" 146 | tokenizer = Tokenizer(source) 147 | tokens = list(tokenizer.tokenize()) 148 | snapshot.assert_match(ruff_format(repr(tokens)), f"tokenizer-strings-{request.node.callspec.id}.txt") 149 | 150 | 151 | @pytest.mark.parametrize( 152 | "source", 153 | [ 154 | "f'test'", 155 | 'f"test"', 156 | "f'''test'''", 157 | 'f"""test"""', 158 | "f'{1}'", 159 | 'f"{1}+{1}={2}"', 160 | 'f"{f"{1}"}"', 161 | 'f"""\nHello, {world}!\n"""', 162 | 'f"Hello, {world}!"', 163 | ], 164 | ids=itertools.count(1), 165 | ) 166 | def test_fstrings(request, snapshot, source: str): 167 | snapshot.snapshot_dir = Path(__file__).parent / "data" 168 | tokenizer = Tokenizer(source) 169 | tokens = list(tokenizer.tokenize()) 170 | snapshot.assert_match(ruff_format(repr(tokens)), f"tokenizer-fstrings-{request.node.callspec.id}.txt") 171 | 172 | 173 | @pytest.mark.parametrize( 174 | ("source", "error_msg"), 175 | [ 176 | ('"""', 'Error at line 1:\n"""\n^^^\nUnterminated string'), 177 | ("'''", "Error at line 1:\n'''\n^^^\nUnterminated string"), 178 | ('"', 'Error at line 1:\n"\n^\nUnterminated string'), 179 | ("'", "Error at line 1:\n'\n^\nUnterminated string"), 180 | ], 181 | ids=itertools.count(1), 182 | ) 183 | def test_errors(source: str, error_msg: str): 184 | tokenizer = Tokenizer(source) 185 | 186 | with pytest.raises(TokenizerError) as excinfo: 187 | list(tokenizer.tokenize()) 188 | assert str(excinfo.value) == error_msg 189 | 190 | 191 | @pytest.mark.parametrize( 192 | "source", 193 | [ 194 | "", 195 | "", 196 | "", 197 | "", 198 | "", 199 | "", 200 | ], 201 | ) 202 | def test_custom_elements(request, snapshot, source: str): 203 | snapshot.snapshot_dir = Path(__file__).parent / "data" 204 | tokenizer = Tokenizer(source) 205 | tokens = list(tokenizer.tokenize()) 206 | snapshot.assert_match(ruff_format(repr(tokens)), f"tokenizer-custom-elements-{request.node.callspec.id}.txt") 207 | 208 | 209 | @pytest.mark.parametrize( 210 | "source", 211 | [ 212 | "<-bar>", 213 | "<-bar />", 214 | "<->", 215 | "<- />", 216 | ], 217 | ) 218 | def test_invalid_custom_elements(source: str): 219 | tokenizer = Tokenizer(source) 220 | with pytest.raises(TokenizerError) as excinfo: 221 | list(tokenizer.tokenize()) 222 | assert "Unexpected token" in str(excinfo.value) 223 | -------------------------------------------------------------------------------- /tests/test_transpilation.py: -------------------------------------------------------------------------------- 1 | import contextlib 2 | import itertools 3 | import sys 4 | import warnings 5 | from collections.abc import Iterator 6 | from pathlib import Path 7 | 8 | import pytest 9 | 10 | from pyjsx.transpiler import ParseError, transpile 11 | 12 | 13 | @pytest.mark.parametrize( 14 | ("source", "expected"), 15 | [ 16 | ("
", 'jsx("div", {}, [])'), 17 | ("", 'jsx("input", {}, [])'), 18 | ("<>", "jsx(jsx.Fragment, {}, [])"), 19 | ("
Hello, world!
", 'jsx("div", {}, ["Hello, world!"])'), 20 | ], 21 | ) 22 | def test_simple(source: str, expected: str): 23 | assert transpile(source) == expected 24 | 25 | 26 | @pytest.mark.parametrize( 27 | ("source", "expected"), 28 | [ 29 | ("
", "jsx(\"div\", {'foo': 'bar'}, [])"), 30 | ( 31 | "
", 32 | "jsx(\"div\", {'foo': 'bar', 'ham': 'spam'}, [])", 33 | ), 34 | ("", "jsx(\"input\", {'type': 'number'}, [])"), 35 | ( 36 | "", 37 | 'jsx("button", {\'disabled\': True}, ["Hello, world!"])', 38 | ), 39 | ], 40 | ) 41 | def test_simple_attributes(source: str, expected: str): 42 | assert transpile(source) == expected 43 | 44 | 45 | @pytest.mark.parametrize( 46 | ("source", "expected"), 47 | [ 48 | ("
", "jsx(\"div\", {'foo': 'bar'} | (x), [])"), 49 | ( 50 | "", 51 | "jsx(\"input\", (x) | {'type': 'number'} | (y), [])", 52 | ), 53 | ( 54 | "", 55 | 'jsx("button", {\'disabled\': True} | (x), ["Hello, world!"])', 56 | ), 57 | ( 58 | "", 59 | "jsx(\"input\", ({'foo': 'bar'}) | (x.y) | (yield foo()), [])", 60 | ), 61 | ], 62 | ) 63 | def test_spread_attributes(source: str, expected: str): 64 | assert transpile(source) == expected 65 | 66 | 67 | @pytest.mark.parametrize( 68 | ("source", "expected"), 69 | [ 70 | ("
", "jsx(\"div\", {'foo': 'bar'} | (x), [])"), 71 | ( 72 | "", 73 | "jsx(\"input\", {'value': 2+3, 'type': 'number'} | (y), [])", 74 | ), 75 | ( 76 | "", 77 | "jsx(\"button\", {'foo': [1, 2, 3], 'disabled': True} | (x), [\"Hello, world!\"])", 78 | ), 79 | ], 80 | ) 81 | def test_expression_attributes(source: str, expected: str): 82 | assert transpile(source) == expected 83 | 84 | 85 | @pytest.mark.parametrize( 86 | ("source", "expected"), 87 | [ 88 | ( 89 | "
bold>
", 90 | 'jsx("div", {\'foo\': jsx("b", {}, ["bold"])}, [])', 91 | ), 92 | ( 93 | "
bold >
", 94 | 'jsx("div", {\'foo\': jsx("b", {}, ["bold"])}, [])', 95 | ), 96 | ( 97 | "
bold bar=link >
", 98 | 'jsx("div", {\'foo\': jsx("b", {}, ["bold"]), \'bar\': jsx("a", {\'href\': \'test.com\'}, ["link"])}, [])', 99 | ), 100 | ( 101 | " type='number' {...y} />", 102 | "jsx(\"input\", {'frag': jsx(jsx.Fragment, {}, []), 'type': 'number'} | (y), [])", 103 | ), 104 | ], 105 | ) 106 | def test_jsx_attributes(source: str, expected: str): 107 | assert transpile(source) == expected 108 | 109 | 110 | @pytest.mark.parametrize( 111 | ("source", "expected"), 112 | [ 113 | ("
", 'jsx("div", {}, [jsx("button", {}, [])])'), 114 | ("<>", 'jsx(jsx.Fragment, {}, [jsx("b", {}, [])])'), 115 | ( 116 | "<>test", 117 | 'jsx(jsx.Fragment, {}, [jsx("b", {}, [jsx("i", {}, ["test"])])])', 118 | ), 119 | ( 120 | "
Hello, world!
", 121 | 'jsx("div", {}, [jsx("b", {}, ["Hello, world!"])])', 122 | ), 123 | ], 124 | ) 125 | def test_simple_nesting(source: str, expected: str): 126 | assert transpile(source) == expected 127 | 128 | 129 | @pytest.mark.parametrize( 130 | ("source", "expected"), 131 | [ 132 | ("
", 'jsx("div", {}, [jsx("button", {}, [])])'), 133 | ("<>", 'jsx(jsx.Fragment, {}, [jsx("b", {}, [])])'), 134 | ( 135 | "<>test", 136 | 'jsx(jsx.Fragment, {}, [jsx("b", {}, [jsx("i", {}, ["test"])])])', 137 | ), 138 | ( 139 | "
Hello, world!
", 140 | 'jsx("div", {}, [jsx("b", {}, ["Hello, world!"])])', 141 | ), 142 | ], 143 | ) 144 | def test_multiple_children(source: str, expected: str): 145 | assert transpile(source) == expected 146 | 147 | 148 | @pytest.mark.parametrize( 149 | "source", 150 | [ 151 | """\ 152 |
153 | Click 154 | 155 | or there 156 |
""", 157 | """\ 158 |
    159 |
  • First
  • 160 |
  • Second
  • 161 |
  • Third
  • 162 |
""", 163 | """\ 164 | def Header(props): 165 | title = props["title"] 166 | return

{title}

167 | 168 | 169 | def Body(props): 170 | return
{props["children"]}
171 | 172 | 173 | def App(): 174 | return ( 175 | 176 | some 177 | text 178 |
179 | more 180 | text 181 | 182 | )""", 183 | ], 184 | ids=itertools.count(1), 185 | ) 186 | def test_multiline(snapshot, request, source: str): 187 | snapshot.snapshot_dir = Path(__file__).parent / "data" 188 | snapshot.assert_match(transpile(source), f"transpiler-multiline-{request.node.callspec.id}.txt") 189 | 190 | 191 | @pytest.mark.parametrize( 192 | "source", 193 | [ 194 | '"\\""', 195 | "'\\''", 196 | '"""""\\""""', 197 | "'''''\\''''", 198 | ], 199 | ) 200 | def test_string_escapes(source: str): 201 | assert transpile(source) == source 202 | 203 | 204 | @pytest.mark.parametrize( 205 | ("source", "expected"), 206 | [ 207 | ('
  • "quoted text"
  • ', 'jsx("li", {}, ["\\"quoted text\\""])'), 208 | ], 209 | ) 210 | def test_jsx_text_escapes(source: str, expected: str): 211 | assert transpile(source) == expected 212 | 213 | 214 | @pytest.mark.parametrize( 215 | ("source", "expected"), 216 | [ 217 | ("
  • <>foo
  • ", 'jsx("li", {}, [jsx(jsx.Fragment, {}, ["foo"])])'), 218 | ], 219 | ) 220 | def test_child_fragments(source: str, expected: str): 221 | assert transpile(source) == expected 222 | 223 | 224 | def _get_stdlib_python_modules() -> Iterator[Path]: 225 | modules = sys.stdlib_module_names 226 | for name in modules: 227 | if name == "antigravity": 228 | # Importing antigravity opens a web browser which is annoying when running tests 229 | continue 230 | 231 | module = None 232 | with contextlib.suppress(Exception), warnings.catch_warnings(): 233 | warnings.filterwarnings("ignore", category=DeprecationWarning) 234 | module = __import__(name) 235 | 236 | if (file_ := getattr(module, "__file__", None)) is None: 237 | continue 238 | 239 | file_ = Path(file_) 240 | if file_.suffix != ".py": 241 | continue 242 | 243 | yield file_ 244 | 245 | 246 | @pytest.mark.parametrize("module_path", _get_stdlib_python_modules()) 247 | def test_roundtrip(module_path: Path): 248 | source = module_path.read_text("utf-8") 249 | assert transpile(source) == source 250 | 251 | 252 | def test_mismatched_closing_tags(): 253 | with pytest.raises(ParseError, match="Expected closing tag , got "): 254 | transpile("
    ") 255 | 256 | 257 | def test_unclosed_tag(): 258 | with pytest.raises(ParseError, match="No more tokens"): 259 | transpile("
    ") 260 | 261 | 262 | def test_unclosed_tag_with_attributes(): 263 | with pytest.raises(ParseError, match="No more tokens"): 264 | transpile("
    ") 265 | 266 | 267 | def test_unclosed_tag_with_children(): 268 | with pytest.raises(ParseError, match="No more tokens"): 269 | transpile("
    ") 270 | 271 | 272 | def test_unclosed_fragment(): 273 | with pytest.raises(ParseError, match="No more tokens"): 274 | transpile("<>") 275 | 276 | 277 | def test_unclosed_fragment_with_children(): 278 | with pytest.raises(ParseError, match="No more tokens"): 279 | transpile("<>") 280 | 281 | 282 | @pytest.mark.parametrize( 283 | ("source", "expected"), 284 | [ 285 | ("", 'jsx("turbo-frame", {}, [])'), 286 | ("", 'jsx("turbo-frame", {}, [])'), 287 | ], 288 | ) 289 | def test_custom_elements(source: str, expected: str): 290 | assert transpile(source) == expected 291 | -------------------------------------------------------------------------------- /logo_bungee_tint.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /pyjsx/transpiler.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from dataclasses import dataclass 5 | from io import StringIO 6 | 7 | from pyjsx.elements import is_builtin_element 8 | from pyjsx.tokenizer import Token, Tokenizer, TokenType 9 | 10 | 11 | UNESCAPED_QUOTES = re.compile(r'(? str: 34 | children = ", ".join(str(child) for child in self.children) 35 | return f"jsx(jsx.Fragment, {{}}, [{children}])" 36 | 37 | 38 | @dataclass 39 | class JSXElement: 40 | name: str 41 | attributes: list[JSXNamedAttribute | JSXSpreadAttribute] 42 | children: list[JSXValue] 43 | 44 | def __str__(self) -> str: 45 | condensed = list[JSXValue | dict[str, JSXValue]]() 46 | curr = {} 47 | for attr in self.attributes: 48 | match attr: 49 | case JSXNamedAttribute(name, value): 50 | curr[name] = value 51 | case JSXSpreadAttribute(value): 52 | if curr: 53 | condensed.append(curr) 54 | curr = {} 55 | condensed.append(value) 56 | case _: 57 | msg = "Invalid attribute" 58 | raise ParseError(msg) 59 | if curr: 60 | condensed.append(curr) 61 | 62 | condensed = condensed or [{}] 63 | attributes = " | ".join(sringify_attribute_dict(attrs) for attrs in condensed) 64 | children = ", ".join(str(child) for child in self.children) 65 | tag = f'"{self.name}"' if is_builtin_element(self.name) else self.name 66 | return f"jsx({tag}, {attributes}, [{children}])" 67 | 68 | 69 | def sringify_attribute_dict(attrs: JSXValue | dict[str, JSXValue]) -> str: 70 | if isinstance(attrs, JSXValue): 71 | return f"({attrs})" 72 | if not attrs: 73 | return "{}" 74 | kvs = ", ".join(f"'{k}': {v}" for k, v in attrs.items()) 75 | return f"{{{kvs}}}" 76 | 77 | 78 | @dataclass 79 | class JSXText: 80 | value: str 81 | 82 | def __str__(self) -> str: 83 | value = re.sub(UNESCAPED_QUOTES, '\\"', self.value) 84 | return f'"{value}"' 85 | 86 | 87 | @dataclass 88 | class JSXExpression: 89 | children: list[JSXValue] 90 | 91 | def __str__(self) -> str: 92 | return "".join(str(child) for child in self.children) 93 | 94 | 95 | JSXValue = JSXElement | JSXFragment | JSXExpression | JSXText | str 96 | 97 | 98 | class TokenQueue: 99 | def __init__(self, tokens: list[Token], offset: int = 0, raw: str = ""): 100 | self.tokens = list(tokens) 101 | self.raw = raw 102 | self.curr = offset 103 | 104 | def peek(self) -> Token | None: 105 | if self.curr >= len(self.tokens): 106 | return None 107 | return self.tokens[self.curr] 108 | 109 | def peek2(self) -> Token | None: 110 | if self.curr + 1 >= len(self.tokens): 111 | return None 112 | return self.tokens[self.curr + 1] 113 | 114 | def peek_type(self, typ: TokenType, value: str | None = None) -> Token | None: 115 | token = self.peek() 116 | if token and token.type == typ and (not value or token.value == value): 117 | return token 118 | return None 119 | 120 | def peek_type2(self, typ: TokenType, value: str | None = None) -> Token | None: 121 | token = self.peek2() 122 | if token and token.type == typ and (not value or token.value == value): 123 | return token 124 | return None 125 | 126 | def pop(self, typ: TokenType | None = None) -> Token: 127 | if self.curr >= len(self.tokens): 128 | msg = "No more tokens" 129 | raise ParseError(msg) 130 | 131 | self.curr += 1 132 | token = self.tokens[self.curr - 1] 133 | if typ and token.type != typ: 134 | msg = f"Expected {typ}, got {token.type}, {token.value}" 135 | raise ParseError(msg) 136 | return token 137 | 138 | def pop_type(self, typ: TokenType) -> Token: 139 | return self.pop(typ=typ) 140 | 141 | 142 | def parse_jsx(queue: TokenQueue) -> JSXElement | JSXFragment: 143 | if queue.peek_type(TokenType.JSX_OPEN): 144 | return parse_jsx_element(queue) 145 | if queue.peek_type(TokenType.JSX_FRAGMENT_OPEN): 146 | return parse_jsx_fragment(queue) 147 | msg = f"Unexpected token {queue.peek()}" 148 | raise ParseError(msg) 149 | 150 | 151 | def parse_jsx_element(queue: TokenQueue) -> JSXElement: 152 | queue.pop_type(TokenType.JSX_OPEN) 153 | name = queue.pop_type(TokenType.ELEMENT_NAME).value 154 | attributes = [] 155 | if not queue.peek_type(TokenType.JSX_CLOSE) and not queue.peek_type(TokenType.JSX_SLASH_CLOSE): 156 | attributes = parse_jsx_attributes(queue) 157 | if queue.peek_type(TokenType.JSX_SLASH_CLOSE): 158 | queue.pop() 159 | return JSXElement(name, attributes, []) 160 | 161 | queue.pop_type(TokenType.JSX_CLOSE) 162 | children = list[JSXValue]() 163 | if not queue.peek_type(TokenType.JSX_SLASH_OPEN): 164 | children = parse_jsx_children(queue) 165 | queue.pop_type(TokenType.JSX_SLASH_OPEN) 166 | closing_tag = queue.pop_type(TokenType.ELEMENT_NAME).value 167 | if closing_tag != name: 168 | msg = f"Expected closing tag , got " 169 | raise ParseError(msg) 170 | queue.pop_type(TokenType.JSX_CLOSE) 171 | return JSXElement(name, attributes, children) 172 | 173 | 174 | def parse_jsx_fragment(queue: TokenQueue) -> JSXFragment: 175 | queue.pop_type(TokenType.JSX_FRAGMENT_OPEN) 176 | children = [] 177 | if not queue.peek_type(TokenType.JSX_FRAGMENT_CLOSE): 178 | children = parse_jsx_children(queue) 179 | queue.pop_type(TokenType.JSX_FRAGMENT_CLOSE) 180 | return JSXFragment(children) 181 | 182 | 183 | def parse_jsx_children(queue: TokenQueue) -> list[JSXValue]: 184 | children = list[JSXValue]() 185 | while not queue.peek_type(TokenType.JSX_SLASH_OPEN) and not queue.peek_type(TokenType.JSX_FRAGMENT_CLOSE): 186 | if queue.peek_type(TokenType.JSX_OPEN): 187 | children.append(parse_jsx_element(queue)) 188 | elif queue.peek_type(TokenType.JSX_FRAGMENT_OPEN): 189 | children.append(parse_jsx_fragment(queue)) 190 | elif queue.peek_type(TokenType.JSX_OPEN_BRACE): 191 | children.append(parse_python_expression(queue)) 192 | else: 193 | jsx_text = parse_jsx_text(queue) 194 | if jsx_text: 195 | children.append(jsx_text) 196 | return children 197 | 198 | 199 | def parse_jsx_text(queue: TokenQueue) -> JSXText | None: 200 | value = queue.pop_type(TokenType.JSX_TEXT).value 201 | lines = value.split("\n") 202 | lines = [line.strip() for line in lines] 203 | lines = [line for line in lines if line] 204 | if not lines: 205 | return None 206 | value = " ".join(lines) 207 | return JSXText(value) 208 | 209 | 210 | def parse_jsx_attributes(queue: TokenQueue) -> list[JSXNamedAttribute | JSXSpreadAttribute]: 211 | attributes = list[JSXNamedAttribute | JSXSpreadAttribute]() 212 | while not queue.peek_type(TokenType.JSX_CLOSE) and not queue.peek_type(TokenType.JSX_SLASH_CLOSE): 213 | if queue.peek_type(TokenType.ATTRIBUTE): 214 | attributes.append(parse_named_attribute(queue)) 215 | elif queue.peek_type(TokenType.JSX_OPEN_BRACE) and queue.peek_type2(TokenType.JSX_SPREAD): 216 | attributes.append(parse_jsx_spread_attribute(queue)) 217 | else: 218 | msg = f"Unexpected token while parsing JSX attributes: {queue.peek()}" 219 | raise ParseError(msg) 220 | return attributes 221 | 222 | 223 | def parse_named_attribute(queue: TokenQueue) -> JSXNamedAttribute: 224 | name = queue.pop_type(TokenType.ATTRIBUTE).value 225 | if queue.peek_type(TokenType.OP, value="="): 226 | queue.pop() 227 | value = parse_jsx_attribute_value(queue) 228 | else: 229 | value = "True" 230 | return JSXNamedAttribute(name, value) 231 | 232 | 233 | def parse_jsx_spread_attribute(queue: TokenQueue) -> JSXSpreadAttribute: 234 | return JSXSpreadAttribute(parse_python_expression(queue, pop_spread=True)) 235 | 236 | 237 | def parse_jsx_attribute_value(queue: TokenQueue) -> str | JSXExpression | JSXElement | JSXFragment: 238 | if queue.peek_type(TokenType.ATTRIBUTE_VALUE): 239 | return queue.pop().value 240 | if queue.peek_type(TokenType.JSX_OPEN_BRACE): 241 | return parse_python_expression(queue) 242 | if queue.peek_type(TokenType.JSX_OPEN): 243 | return parse_jsx_element(queue) 244 | if queue.peek_type(TokenType.JSX_FRAGMENT_OPEN): 245 | return parse_jsx_fragment(queue) 246 | msg = f"Unexpected token while parsing JSX attribute value: {queue.peek()}" 247 | raise ParseError(msg) 248 | 249 | 250 | def parse_python_expression(queue: TokenQueue, *, pop_spread: bool = False) -> JSXExpression: 251 | queue.pop_type(TokenType.JSX_OPEN_BRACE) 252 | if pop_spread: 253 | queue.pop_type(TokenType.JSX_SPREAD) 254 | children = list[JSXValue]() 255 | while not queue.peek_type(TokenType.JSX_CLOSE_BRACE): 256 | if queue.peek_type(TokenType.JSX_OPEN) or queue.peek_type(TokenType.JSX_FRAGMENT_OPEN): 257 | children.append(parse_jsx(queue)) 258 | else: 259 | children.append(queue.pop().value) 260 | queue.pop_type(TokenType.JSX_CLOSE_BRACE) 261 | return JSXExpression(children) 262 | 263 | 264 | class Transpiler: 265 | def __init__(self, source: str): 266 | self.source = source 267 | self.tokenizer = Tokenizer(source) 268 | self.output = StringIO() 269 | self.curr = 0 270 | 271 | def transpile(self) -> str: 272 | tokens = list(self.tokenizer.tokenize()) 273 | while self.curr < len(tokens): 274 | if tokens[self.curr].type not in {TokenType.JSX_OPEN, TokenType.JSX_FRAGMENT_OPEN}: 275 | self.output.write(tokens[self.curr].value) 276 | self.curr += 1 277 | else: 278 | queue = TokenQueue(tokens, self.curr, raw=self.source) 279 | jsx = parse_jsx(queue) 280 | self.curr = queue.curr 281 | self.output.write(str(jsx)) 282 | return self.output.getvalue() 283 | 284 | 285 | def transpile(source: str) -> str: 286 | transpiler = Transpiler(source) 287 | return transpiler.transpile() 288 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /plugin-vscode/LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /plugin-vscode/syntaxes/MagicRegExp.tmLanguage.json: -------------------------------------------------------------------------------- 1 | { 2 | "information_for_contributors": [ 3 | "This file has been converted from https://github.com/MagicStack/MagicPython/blob/master/grammars/MagicRegExp.tmLanguage" 4 | ], 5 | "version": "https://github.com/MagicStack/MagicPython/commit/c9b3409deb69acec31bbf7913830e93a046b30cc", 6 | "name": "MagicRegExp", 7 | "scopeName": "source.regexp.pyjsx", 8 | "patterns": [ 9 | { 10 | "include": "#regexp-expression" 11 | } 12 | ], 13 | "repository": { 14 | "regexp-base-expression": { 15 | "patterns": [ 16 | { 17 | "include": "#regexp-quantifier" 18 | }, 19 | { 20 | "include": "#regexp-base-common" 21 | } 22 | ] 23 | }, 24 | "fregexp-base-expression": { 25 | "patterns": [ 26 | { 27 | "include": "#fregexp-quantifier" 28 | }, 29 | { 30 | "include": "#fstring-formatting-braces" 31 | }, 32 | { 33 | "match": "\\{.*?\\}" 34 | }, 35 | { 36 | "include": "#regexp-base-common" 37 | } 38 | ] 39 | }, 40 | "fstring-formatting-braces": { 41 | "patterns": [ 42 | { 43 | "comment": "empty braces are illegal", 44 | "match": "({)(\\s*?)(})", 45 | "captures": { 46 | "1": { 47 | "name": "constant.character.format.placeholder.other.python" 48 | }, 49 | "2": { 50 | "name": "invalid.illegal.brace.python" 51 | }, 52 | "3": { 53 | "name": "constant.character.format.placeholder.other.python" 54 | } 55 | } 56 | }, 57 | { 58 | "name": "constant.character.escape.python", 59 | "match": "({{|}})" 60 | } 61 | ] 62 | }, 63 | "regexp-base-common": { 64 | "patterns": [ 65 | { 66 | "name": "support.other.match.any.regexp", 67 | "match": "\\." 68 | }, 69 | { 70 | "name": "support.other.match.begin.regexp", 71 | "match": "\\^" 72 | }, 73 | { 74 | "name": "support.other.match.end.regexp", 75 | "match": "\\$" 76 | }, 77 | { 78 | "name": "keyword.operator.quantifier.regexp", 79 | "match": "[+*?]\\??" 80 | }, 81 | { 82 | "name": "keyword.operator.disjunction.regexp", 83 | "match": "\\|" 84 | }, 85 | { 86 | "include": "#regexp-escape-sequence" 87 | } 88 | ] 89 | }, 90 | "regexp-quantifier": { 91 | "name": "keyword.operator.quantifier.regexp", 92 | "match": "(?x)\n \\{(\n \\d+ | \\d+,(\\d+)? | ,\\d+\n )\\}\n" 93 | }, 94 | "fregexp-quantifier": { 95 | "name": "keyword.operator.quantifier.regexp", 96 | "match": "(?x)\n \\{\\{(\n \\d+ | \\d+,(\\d+)? | ,\\d+\n )\\}\\}\n" 97 | }, 98 | "regexp-backreference-number": { 99 | "name": "meta.backreference.regexp", 100 | "match": "(\\\\[1-9]\\d?)", 101 | "captures": { 102 | "1": { 103 | "name": "entity.name.tag.backreference.regexp" 104 | } 105 | } 106 | }, 107 | "regexp-backreference": { 108 | "name": "meta.backreference.named.regexp", 109 | "match": "(?x)\n (\\() (\\?P= \\w+(?:\\s+[[:alnum:]]+)?) (\\))\n", 110 | "captures": { 111 | "1": { 112 | "name": "support.other.parenthesis.regexp punctuation.parenthesis.backreference.named.begin.regexp" 113 | }, 114 | "2": { 115 | "name": "entity.name.tag.named.backreference.regexp" 116 | }, 117 | "3": { 118 | "name": "support.other.parenthesis.regexp punctuation.parenthesis.backreference.named.end.regexp" 119 | } 120 | } 121 | }, 122 | "regexp-flags": { 123 | "name": "storage.modifier.flag.regexp", 124 | "match": "\\(\\?[aiLmsux]+\\)" 125 | }, 126 | "regexp-escape-special": { 127 | "name": "support.other.escape.special.regexp", 128 | "match": "\\\\([AbBdDsSwWZ])" 129 | }, 130 | "regexp-escape-character": { 131 | "name": "constant.character.escape.regexp", 132 | "match": "(?x)\n \\\\ (\n x[0-9A-Fa-f]{2}\n | 0[0-7]{1,2}\n | [0-7]{3}\n )\n" 133 | }, 134 | "regexp-escape-unicode": { 135 | "name": "constant.character.unicode.regexp", 136 | "match": "(?x)\n \\\\ (\n u[0-9A-Fa-f]{4}\n | U[0-9A-Fa-f]{8}\n )\n" 137 | }, 138 | "regexp-escape-catchall": { 139 | "name": "constant.character.escape.regexp", 140 | "match": "\\\\(.|\\n)" 141 | }, 142 | "regexp-escape-sequence": { 143 | "patterns": [ 144 | { 145 | "include": "#regexp-escape-special" 146 | }, 147 | { 148 | "include": "#regexp-escape-character" 149 | }, 150 | { 151 | "include": "#regexp-escape-unicode" 152 | }, 153 | { 154 | "include": "#regexp-backreference-number" 155 | }, 156 | { 157 | "include": "#regexp-escape-catchall" 158 | } 159 | ] 160 | }, 161 | "regexp-charecter-set-escapes": { 162 | "patterns": [ 163 | { 164 | "name": "constant.character.escape.regexp", 165 | "match": "\\\\[abfnrtv\\\\]" 166 | }, 167 | { 168 | "include": "#regexp-escape-special" 169 | }, 170 | { 171 | "name": "constant.character.escape.regexp", 172 | "match": "\\\\([0-7]{1,3})" 173 | }, 174 | { 175 | "include": "#regexp-escape-character" 176 | }, 177 | { 178 | "include": "#regexp-escape-unicode" 179 | }, 180 | { 181 | "include": "#regexp-escape-catchall" 182 | } 183 | ] 184 | }, 185 | "codetags": { 186 | "match": "(?:\\b(NOTE|XXX|HACK|FIXME|BUG|TODO)\\b)", 187 | "captures": { 188 | "1": { 189 | "name": "keyword.codetag.notation.python" 190 | } 191 | } 192 | }, 193 | "regexp-expression": { 194 | "patterns": [ 195 | { 196 | "include": "#regexp-base-expression" 197 | }, 198 | { 199 | "include": "#regexp-character-set" 200 | }, 201 | { 202 | "include": "#regexp-comments" 203 | }, 204 | { 205 | "include": "#regexp-flags" 206 | }, 207 | { 208 | "include": "#regexp-named-group" 209 | }, 210 | { 211 | "include": "#regexp-backreference" 212 | }, 213 | { 214 | "include": "#regexp-lookahead" 215 | }, 216 | { 217 | "include": "#regexp-lookahead-negative" 218 | }, 219 | { 220 | "include": "#regexp-lookbehind" 221 | }, 222 | { 223 | "include": "#regexp-lookbehind-negative" 224 | }, 225 | { 226 | "include": "#regexp-conditional" 227 | }, 228 | { 229 | "include": "#regexp-parentheses-non-capturing" 230 | }, 231 | { 232 | "include": "#regexp-parentheses" 233 | } 234 | ] 235 | }, 236 | "regexp-character-set": { 237 | "patterns": [ 238 | { 239 | "match": "(?x)\n \\[ \\^? \\] (?! .*?\\])\n" 240 | }, 241 | { 242 | "name": "meta.character.set.regexp", 243 | "begin": "(\\[)(\\^)?(\\])?", 244 | "end": "(\\])", 245 | "beginCaptures": { 246 | "1": { 247 | "name": "punctuation.character.set.begin.regexp constant.other.set.regexp" 248 | }, 249 | "2": { 250 | "name": "keyword.operator.negation.regexp" 251 | }, 252 | "3": { 253 | "name": "constant.character.set.regexp" 254 | } 255 | }, 256 | "endCaptures": { 257 | "1": { 258 | "name": "punctuation.character.set.end.regexp constant.other.set.regexp" 259 | }, 260 | "2": { 261 | "name": "invalid.illegal.newline.python" 262 | } 263 | }, 264 | "patterns": [ 265 | { 266 | "include": "#regexp-charecter-set-escapes" 267 | }, 268 | { 269 | "name": "constant.character.set.regexp", 270 | "match": "[^\\n]" 271 | } 272 | ] 273 | } 274 | ] 275 | }, 276 | "regexp-named-group": { 277 | "name": "meta.named.regexp", 278 | "begin": "(?x)\n (\\() (\\?P <\\w+(?:\\s+[[:alnum:]]+)?>)\n", 279 | "end": "(\\))", 280 | "beginCaptures": { 281 | "1": { 282 | "name": "support.other.parenthesis.regexp punctuation.parenthesis.named.begin.regexp" 283 | }, 284 | "2": { 285 | "name": "entity.name.tag.named.group.regexp" 286 | } 287 | }, 288 | "endCaptures": { 289 | "1": { 290 | "name": "support.other.parenthesis.regexp punctuation.parenthesis.named.end.regexp" 291 | }, 292 | "2": { 293 | "name": "invalid.illegal.newline.python" 294 | } 295 | }, 296 | "patterns": [ 297 | { 298 | "include": "#regexp-expression" 299 | } 300 | ] 301 | }, 302 | "regexp-comments": { 303 | "name": "comment.regexp", 304 | "begin": "\\(\\?#", 305 | "end": "(\\))", 306 | "beginCaptures": { 307 | "0": { 308 | "name": "punctuation.comment.begin.regexp" 309 | } 310 | }, 311 | "endCaptures": { 312 | "1": { 313 | "name": "punctuation.comment.end.regexp" 314 | }, 315 | "2": { 316 | "name": "invalid.illegal.newline.python" 317 | } 318 | }, 319 | "patterns": [ 320 | { 321 | "include": "#codetags" 322 | } 323 | ] 324 | }, 325 | "regexp-lookahead": { 326 | "begin": "(\\()\\?=", 327 | "end": "(\\))", 328 | "beginCaptures": { 329 | "0": { 330 | "name": "keyword.operator.lookahead.regexp" 331 | }, 332 | "1": { 333 | "name": "punctuation.parenthesis.lookahead.begin.regexp" 334 | } 335 | }, 336 | "endCaptures": { 337 | "1": { 338 | "name": "keyword.operator.lookahead.regexp punctuation.parenthesis.lookahead.end.regexp" 339 | }, 340 | "2": { 341 | "name": "invalid.illegal.newline.python" 342 | } 343 | }, 344 | "patterns": [ 345 | { 346 | "include": "#regexp-expression" 347 | } 348 | ] 349 | }, 350 | "regexp-lookahead-negative": { 351 | "begin": "(\\()\\?!", 352 | "end": "(\\))", 353 | "beginCaptures": { 354 | "0": { 355 | "name": "keyword.operator.lookahead.negative.regexp" 356 | }, 357 | "1": { 358 | "name": "punctuation.parenthesis.lookahead.begin.regexp" 359 | } 360 | }, 361 | "endCaptures": { 362 | "1": { 363 | "name": "keyword.operator.lookahead.negative.regexp punctuation.parenthesis.lookahead.end.regexp" 364 | }, 365 | "2": { 366 | "name": "invalid.illegal.newline.python" 367 | } 368 | }, 369 | "patterns": [ 370 | { 371 | "include": "#regexp-expression" 372 | } 373 | ] 374 | }, 375 | "regexp-lookbehind": { 376 | "begin": "(\\()\\?<=", 377 | "end": "(\\))", 378 | "beginCaptures": { 379 | "0": { 380 | "name": "keyword.operator.lookbehind.regexp" 381 | }, 382 | "1": { 383 | "name": "punctuation.parenthesis.lookbehind.begin.regexp" 384 | } 385 | }, 386 | "endCaptures": { 387 | "1": { 388 | "name": "keyword.operator.lookbehind.regexp punctuation.parenthesis.lookbehind.end.regexp" 389 | }, 390 | "2": { 391 | "name": "invalid.illegal.newline.python" 392 | } 393 | }, 394 | "patterns": [ 395 | { 396 | "include": "#regexp-expression" 397 | } 398 | ] 399 | }, 400 | "regexp-lookbehind-negative": { 401 | "begin": "(\\()\\?= (3, 11): 10 | from enum import StrEnum 11 | else: 12 | from enum import Enum 13 | 14 | class StrEnum(str, Enum): 15 | pass 16 | 17 | 18 | ELEMENT_NAME = re.compile(r"^[_a-zA-Z][\w-]*(?:\.[_a-zA-Z][\w-]*)*") 19 | ATTRIBUTE_NAME = re.compile(r"^[^\s='\"<>{}]+") 20 | ATTRIBUTE_STRING_VALUE = re.compile(r"^(?:'[^']*')|(?:\"[^\"]*\")") 21 | JSX_TEXT = re.compile(r"^[^<>\{\}]+") 22 | WS = re.compile(r"^\s+") 23 | 24 | COMMENT = re.compile(r"^#[^\n]*", re.UNICODE) 25 | SINGLE_LINE_STRING = re.compile(r"^[rRbBuU]*('[^']*')|(\"[^\"]*\")", re.UNICODE) 26 | EXPR_KEYWORDS = re.compile(r"^(else|yield|return)", re.UNICODE) 27 | NAME = re.compile(r"^[a-zA-Z_]\w*", re.UNICODE) 28 | MULTI_LINE_STRING_START = re.compile(r"^[rRbBuU]*('''|\"\"\")", re.UNICODE) 29 | SINGLE_LINE_STRING_START = re.compile(r"^[rRbBuU]*('|\")", re.UNICODE) 30 | FSTRING_START = re.compile(r"^[rRbBuU]*(?:f|F)[rRbBuU]*('''|\"\"\"|'|\")", re.UNICODE) 31 | FSTRING_MIDDLE = re.compile(r"^[^{}]+", re.UNICODE) 32 | JSX_KEYWORDS = re.compile(r"^(return|yield|else)") 33 | 34 | 35 | class TokenType(StrEnum): 36 | OP = "OP" 37 | ELEMENT_NAME = "ELEMENT_NAME" 38 | NAME = "NAME" 39 | JSX_OPEN = "JSX_OPEN" 40 | JSX_SLASH_OPEN = "JSX_SLASH_OPEN" 41 | JSX_CLOSE = "JSX_CLOSE" 42 | JSX_SLASH_CLOSE = "JSX_SLASH_CLOSE" 43 | JSX_FRAGMENT_OPEN = "JSX_FRAGMENT_OPEN" 44 | JSX_FRAGMENT_CLOSE = "JSX_FRAGMENT_CLOSE" 45 | JSX_SPREAD = "JSX_SPREAD" 46 | JSX_TEXT = "JSX_TEXT" 47 | JSX_OPEN_BRACE = "JSX_OPEN_BRACE" 48 | JSX_CLOSE_BRACE = "JSX_CLOSE_BRACE" 49 | ATTRIBUTE = "ATTRIBUTE" 50 | ATTRIBUTE_VALUE = "ATTRIBUTE_VALUE" 51 | WS = "WS" 52 | COMMENT = "COMMENT" 53 | SINGLE_LINE_STRING = "SINGLE_LINE_STRING" 54 | MULTI_LINE_STRING = "MULTI_LINE_STRING" 55 | FSTRING_START = "FSTRING_START" 56 | FSTRING_MIDDLE = "FSTRING_MIDDLE" 57 | FSTRING_END = "FSTRING_END" 58 | ANY = "ANY" 59 | 60 | def __repr__(self) -> str: 61 | return self.value 62 | 63 | 64 | @dataclass 65 | class Token: 66 | type: TokenType 67 | value: str 68 | start: int 69 | end: int 70 | 71 | 72 | @dataclass 73 | class JSXMode: 74 | angle_brackets: int = 0 75 | is_inside_open_tag: bool = False 76 | is_inside_closing_tag: bool = False 77 | expects_element_name: bool = False 78 | expects_spread: bool = False 79 | expects_attribute_value: bool = False 80 | 81 | 82 | @dataclass 83 | class PYMode: 84 | curly_brackets: int = 0 85 | inside_jsx: bool = False 86 | inside_fstring: bool = False 87 | prev_token: str | None = None 88 | 89 | 90 | @dataclass 91 | class FStringMode: 92 | start: str = "" 93 | 94 | 95 | class TokenizerError(Exception): 96 | pass 97 | 98 | 99 | def make_error_message(msg: str, source: str, start: int, end: int) -> str: 100 | line_number, _ = get_line_number_offset(source, start) 101 | highlighted = highlight_line(source, start, end) 102 | 103 | return f"Error at line {line_number}:\n{highlighted}\n{msg}" 104 | 105 | 106 | # Yes, the code is pretty bad, but I didn't feel like refactoring it.. 107 | class Tokenizer: 108 | def __init__(self, source: str, curr: int = 0): 109 | self.source = source 110 | self.curr = curr 111 | self.modes: list[PYMode | JSXMode | FStringMode] = [PYMode()] 112 | 113 | @property 114 | def mode(self) -> PYMode | JSXMode | FStringMode: 115 | return self.modes[-1] 116 | 117 | def tokenize(self) -> Generator[Token, None, None]: 118 | while self.curr < len(self.source): 119 | if isinstance(self.mode, PYMode): 120 | yield from self.tokenize_py() 121 | elif isinstance(self.mode, JSXMode): 122 | yield from self.tokenize_jsx() 123 | if isinstance(self.mode, JSXMode) and self.mode.angle_brackets == 0: 124 | self.modes.pop() 125 | else: 126 | yield from self.tokenize_fstring() 127 | 128 | def tokenize_jsx(self) -> Generator[Token, None, None]: # noqa: C901, PLR0912, PLR0915 129 | assert isinstance(self.mode, JSXMode) 130 | 131 | if self.source[self.curr : self.curr + 3] == "": 132 | self.mode.angle_brackets -= 1 133 | yield Token( 134 | TokenType.JSX_FRAGMENT_CLOSE, 135 | self.source[self.curr : self.curr + 3], 136 | self.curr, 137 | self.curr + 3, 138 | ) 139 | self.curr += 3 140 | elif self.source[self.curr : self.curr + 2] == "<>": 141 | self.mode.angle_brackets += 1 142 | yield Token( 143 | TokenType.JSX_FRAGMENT_OPEN, 144 | self.source[self.curr : self.curr + 2], 145 | self.curr, 146 | self.curr + 2, 147 | ) 148 | self.curr += 2 149 | elif self.source[self.curr : self.curr + 2] == "": 160 | self.mode.is_inside_open_tag = False 161 | self.mode.angle_brackets -= 1 162 | yield Token(TokenType.JSX_SLASH_CLOSE, self.source[self.curr : self.curr + 2], self.curr, self.curr + 2) 163 | self.curr += 2 164 | elif self.source[self.curr] in {"<", ">"}: 165 | if self.source[self.curr] == "<": 166 | if self.mode.is_inside_open_tag: 167 | self.modes.append(JSXMode(is_inside_open_tag=True, angle_brackets=1, expects_element_name=True)) 168 | else: 169 | self.mode.is_inside_open_tag = True 170 | self.mode.angle_brackets += 1 171 | self.mode.expects_element_name = True 172 | yield Token(TokenType.JSX_OPEN, self.source[self.curr], self.curr, self.curr + 1) 173 | else: 174 | self.mode.is_inside_open_tag = False 175 | if self.mode.is_inside_closing_tag: 176 | self.mode.is_inside_closing_tag = False 177 | self.mode.angle_brackets -= 1 178 | yield Token(TokenType.JSX_CLOSE, self.source[self.curr], self.curr, self.curr + 1) 179 | self.curr += 1 180 | elif self.source[self.curr] == "}": 181 | yield Token(TokenType.JSX_CLOSE_BRACE, self.source[self.curr], self.curr, self.curr + 1) 182 | self.curr += 1 183 | elif self.source[self.curr] == "{": 184 | yield Token(TokenType.JSX_OPEN_BRACE, self.source[self.curr], self.curr, self.curr + 1) 185 | self.curr += 1 186 | if self.source[self.curr : self.curr + 3] == "...": 187 | yield Token(TokenType.JSX_SPREAD, self.source[self.curr : self.curr + 3], self.curr, self.curr + 3) 188 | self.curr += 3 189 | self.modes.append(PYMode(curly_brackets=1, inside_jsx=True)) 190 | 191 | elif self.mode.is_inside_open_tag or self.mode.is_inside_closing_tag: 192 | if match := WS.match(self.source[self.curr :]): 193 | self.curr += len(match.group()) 194 | elif self.mode.is_inside_open_tag and self.source[self.curr] == "=": 195 | yield Token(TokenType.OP, self.source[self.curr], self.curr, self.curr + 1) 196 | self.curr += 1 197 | elif self.mode.expects_element_name and (match := ELEMENT_NAME.match(self.source[self.curr :])): 198 | name = match.group() 199 | yield Token(TokenType.ELEMENT_NAME, name, self.curr, self.curr + len(name)) 200 | self.curr += len(name) 201 | self.mode.expects_element_name = False 202 | elif ( 203 | not self.mode.expects_element_name 204 | and self.mode.is_inside_open_tag 205 | and (match := ATTRIBUTE_NAME.match(self.source[self.curr :])) 206 | ): 207 | attr = match.group() 208 | yield Token(TokenType.ATTRIBUTE, attr, self.curr, self.curr + len(attr)) 209 | self.curr += len(attr) 210 | elif self.mode.is_inside_open_tag and (match := ATTRIBUTE_STRING_VALUE.match(self.source[self.curr :])): 211 | value = match.group() 212 | yield Token(TokenType.ATTRIBUTE_VALUE, value, self.curr, self.curr + len(value)) 213 | self.curr += len(value) 214 | else: 215 | msg = f"Unexpected token {self.source[self.curr :]}" 216 | raise TokenizerError(msg) 217 | elif match := JSX_TEXT.match(self.source[self.curr :]): 218 | text = match.group() 219 | yield Token(TokenType.JSX_TEXT, text, self.curr, self.curr + len(text)) 220 | self.curr += len(text) 221 | else: 222 | msg = f"Unexpected token {self.source[self.curr :]}" 223 | raise TokenizerError(msg) 224 | 225 | def tokenize_py(self) -> Generator[Token, None, None]: # noqa: C901, PLR0912, PLR0915 226 | assert isinstance(self.mode, PYMode) 227 | if match := WS.match(self.source[self.curr :]): 228 | yield Token(TokenType.WS, match.group(), self.curr, self.curr + len(match.group())) 229 | self.curr += len(match.group()) 230 | elif match := COMMENT.match(self.source[self.curr :]): 231 | yield Token(TokenType.COMMENT, match.group(), self.curr, self.curr + len(match.group())) 232 | self.curr += len(match.group()) 233 | self.mode.prev_token = match.group() 234 | elif match := MULTI_LINE_STRING_START.match(self.source[self.curr :]): 235 | start = match.group(1) 236 | start_index = self.curr 237 | self.curr += len(match.group()) 238 | string = match.group() 239 | found = False 240 | while self.curr < len(self.source): 241 | if self.source[self.curr : self.curr + 2] == "\\\\": 242 | string += "\\\\" 243 | self.curr += 2 244 | elif self.source[self.curr : self.curr + 2] == "\\'": 245 | string += "\\'" 246 | self.curr += 2 247 | elif self.source[self.curr : self.curr + 2] == '\\"': 248 | string += '\\"' 249 | self.curr += 2 250 | elif self.source[self.curr : self.curr + 3] == start: 251 | string += start 252 | self.curr += 3 253 | found = True 254 | break 255 | else: 256 | string += self.source[self.curr] 257 | self.curr += 1 258 | 259 | if not found: 260 | msg = make_error_message( 261 | "Unterminated string", self.source, start_index, start_index + len(match.group()) 262 | ) 263 | raise TokenizerError(msg) 264 | yield Token(TokenType.MULTI_LINE_STRING, string, start_index, self.curr) 265 | self.mode.prev_token = string 266 | elif match := SINGLE_LINE_STRING_START.match(self.source[self.curr :]): 267 | start = match.group(1) 268 | start_index = self.curr 269 | self.curr += len(match.group()) 270 | string = match.group() 271 | found = False 272 | while self.curr < len(self.source): 273 | if self.source[self.curr : self.curr + 2] == "\\\\": 274 | string += "\\\\" 275 | self.curr += 2 276 | elif self.source[self.curr : self.curr + 2] == "\\'": 277 | string += "\\'" 278 | self.curr += 2 279 | elif self.source[self.curr : self.curr + 2] == '\\"': 280 | string += '\\"' 281 | self.curr += 2 282 | elif self.source[self.curr] == start: 283 | string += start 284 | self.curr += 1 285 | found = True 286 | break 287 | else: 288 | string += self.source[self.curr] 289 | self.curr += 1 290 | 291 | if not found: 292 | msg = make_error_message( 293 | "Unterminated string", self.source, start_index, start_index + len(match.group()) 294 | ) 295 | raise TokenizerError(msg) 296 | yield Token(TokenType.SINGLE_LINE_STRING, string, start_index, self.curr) 297 | self.mode.prev_token = string 298 | elif match := FSTRING_START.match(self.source[self.curr :]): 299 | start = match.group(1) 300 | start_index = self.curr 301 | self.curr += len(match.group()) 302 | string = match.group() 303 | yield Token(TokenType.FSTRING_START, string, start_index, self.curr) 304 | self.mode.prev_token = string 305 | self.modes.append(FStringMode(start=start)) 306 | elif self.source[self.curr] in {":", "(", "[", ",", "=", ":=", "->"}: 307 | yield Token(TokenType.OP, self.source[self.curr], self.curr, self.curr + 1) 308 | self.mode.prev_token = self.source[self.curr] 309 | self.curr += 1 310 | elif self.source[self.curr : self.curr + 2] in {":=", "->"}: 311 | yield Token(TokenType.OP, self.source[self.curr : self.curr + 2], self.curr, self.curr + 2) 312 | self.mode.prev_token = self.source[self.curr : self.curr + 2] 313 | self.curr += 2 314 | elif match := JSX_KEYWORDS.match(self.source[self.curr :]): 315 | yield Token(TokenType.ANY, match.group(), self.curr, self.curr + len(match.group())) 316 | self.curr += len(match.group()) 317 | self.mode.prev_token = match.group() 318 | elif self.source[self.curr] == "{": 319 | self.mode.curly_brackets += 1 320 | yield Token(TokenType.OP, self.source[self.curr], self.curr, self.curr + 1) 321 | self.mode.prev_token = self.source[self.curr] 322 | self.curr += 1 323 | elif self.source[self.curr] == "}": 324 | self.mode.curly_brackets -= 1 325 | if (self.mode.inside_jsx or self.mode.inside_fstring) and self.mode.curly_brackets == 0: 326 | self.modes.pop() 327 | else: 328 | yield Token(TokenType.OP, self.source[self.curr], self.curr, self.curr + 1) 329 | self.mode.prev_token = self.source[self.curr] 330 | self.curr += 1 331 | elif self.source[self.curr] == "<": 332 | if not self.mode.prev_token or self.mode.prev_token in { 333 | "{", 334 | ":", 335 | "(", 336 | "[", 337 | ",", 338 | "=", 339 | ":=", 340 | "->", 341 | "else", 342 | "yield", 343 | "return", 344 | }: 345 | self.modes.append(JSXMode()) 346 | self.mode.inside_jsx = True 347 | else: 348 | yield Token(TokenType.OP, self.source[self.curr], self.curr, self.curr + 1) 349 | self.mode.prev_token = self.source[self.curr] 350 | self.curr += 1 351 | elif match := NAME.match(self.source[self.curr :]): 352 | yield Token(TokenType.NAME, match.group(), self.curr, self.curr + len(match.group())) 353 | self.curr += len(match.group()) 354 | self.mode.prev_token = match.group() 355 | else: 356 | yield Token(TokenType.ANY, self.source[self.curr], self.curr, self.curr + 1) 357 | self.mode.prev_token = self.source[self.curr] 358 | self.curr += 1 359 | 360 | def tokenize_fstring(self) -> Generator[Token, None, None]: 361 | assert isinstance(self.mode, FStringMode) 362 | start = self.mode.start 363 | if self.source[self.curr] == "{": 364 | yield Token(TokenType.OP, self.source[self.curr], self.curr, self.curr + 1) 365 | self.curr += 1 366 | self.modes.append(PYMode(curly_brackets=1, inside_jsx=False, inside_fstring=True)) 367 | elif self.source[self.curr] == "}": 368 | yield Token(TokenType.OP, self.source[self.curr], self.curr, self.curr + 1) 369 | self.curr += 1 370 | elif self.source[self.curr : self.curr + len(start)] == start: 371 | yield Token(TokenType.FSTRING_END, start, self.curr, self.curr + len(start)) 372 | self.curr += len(start) 373 | self.modes.pop() 374 | else: 375 | middle = "" 376 | while self.curr < len(self.source): 377 | if self.source[self.curr : self.curr + 2] == "{{": 378 | middle += "{{" 379 | self.curr += 2 380 | elif self.source[self.curr : self.curr + 2] == "}}": 381 | middle += "}}" 382 | self.curr += 2 383 | elif ( 384 | self.source[self.curr] not in {"{", "}"} 385 | and self.source[self.curr : self.curr + len(start)] != start 386 | ): 387 | middle += self.source[self.curr] 388 | self.curr += 1 389 | else: 390 | break 391 | if not middle: 392 | msg = f"Unexpected token {self.source[self.curr :]}" 393 | raise TokenizerError(msg) 394 | yield Token(TokenType.FSTRING_MIDDLE, middle, self.curr, self.curr + len(middle)) 395 | --------------------------------------------------------------------------------