Skip to content

Commit 4f434c9

Browse files
authored
Improve and document the import hook mechanism (#5)
* Improve import hook * Add tests * Update examples * Update readme * Remove coding directive from import hook examples * Make pyright happy
1 parent c27d432 commit 4f434c9

File tree

26 files changed

+224
-24
lines changed

26 files changed

+224
-24
lines changed

README.md

+43-4
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,13 @@ Get it via pip:
3333
pip install python-jsx
3434
```
3535

36-
## Minimal example
36+
## Minimal example (using the `coding` directive)
37+
38+
> [!TIP]
39+
> There are more examples available in the [examples folder](examples).
40+
41+
There are two supported ways to seamlessly integrate JSX into your codebase.
42+
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).
3743

3844
```python
3945
# hello.py
@@ -58,9 +64,6 @@ $ python main.py
5864
<h1>Hello, word!</h1>
5965
```
6066

61-
> [!TIP]
62-
> There are more examples available in the [examples folder](examples).
63-
6467
Each file containing JSX must contain two things:
6568

6669
- `# coding: jsx` directive - This tells Python to let our library parse the
@@ -72,6 +75,42 @@ To run a file containing JSX, the `jsx` codec must be registered first which can
7275
be done with `from pyjsx import auto_setup`. This must occur before importing
7376
any other file containing JSX.
7477

78+
## Minimal example (using an import hook)
79+
80+
> [!TIP]
81+
> There are more examples available in the [examples folder](examples).
82+
83+
```python
84+
# hello.px
85+
from pyjsx import jsx
86+
87+
def hello():
88+
print(<h1>Hello, world!</h1>)
89+
```
90+
91+
```python
92+
# main.py
93+
from pyjsx import auto_setup
94+
95+
from hello import hello
96+
97+
hello()
98+
```
99+
100+
```sh
101+
$ python main.py
102+
<h1>Hello, word!</h1>
103+
```
104+
105+
Each file containing JSX must contain two things:
106+
107+
- The file extension must be `.px`
108+
- `from pyjsx import jsx` import. PyJSX transpiles JSX into `jsx(...)` calls so
109+
it must be in scope.
110+
111+
To be able to import `.px`, the import hook must be registered first which can
112+
be done with `from pyjsx import auto_setup` (same as for the codec version). This must occur before importing any other file containing JSX.
113+
75114
## Supported grammar
76115

77116
The full [JSX grammar](https://facebook.github.io/jsx/) is supported.

examples/README.md

+6-3
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,9 @@
33
> [!TIP]
44
> Run each example with `python main.py`
55
6-
- table - Shows how you can easily generate an HTML table from data
7-
- custom - Shows how you can use custom components
8-
- props - Shows some advanced props usage
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
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
from pyjsx import jsx, JSX
2+
3+
4+
def Header(children, style=None, **rest) -> JSX:
5+
return <h1 style={style}>{children}</h1>
6+
7+
8+
def Main(children, **rest) -> JSX:
9+
return <main>{children}</main>
10+
11+
12+
def App() -> JSX:
13+
return (
14+
<div>
15+
<Header style={{"color": "red"}}>Hello, world!</Header>
16+
<Main>
17+
<p>This was rendered with PyJSX!</p>
18+
</Main>
19+
</div>
20+
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from pyjsx import auto_setup
2+
3+
from custom import App
4+
5+
6+
print(App())
File renamed without changes.
File renamed without changes.
File renamed without changes.

examples/props_import_hook/__init__.py

Whitespace-only changes.

examples/props_import_hook/main.py

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from pyjsx import auto_setup
2+
3+
from props import App
4+
5+
print(App())

examples/props_import_hook/props.px

+37
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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+
<div style={style}>
11+
{image}
12+
{children}
13+
</div>
14+
)
15+
16+
17+
def Image(src, alt, **rest) -> JSX:
18+
return <img src={src} alt={alt} />
19+
20+
21+
def App() -> JSX:
22+
return (
23+
<div>
24+
<Card rounded raised image={<Image src="dog.jpg" alt="A picture of a dog" />}>
25+
<h1>Card title</h1>
26+
<p>Card content</p>
27+
</Card>
28+
<Card rounded raised={False} disabled image={<Image src="cat.jpg" alt="A picture of a cat" />}>
29+
<h1>Card title</h1>
30+
<p>Card content</p>
31+
</Card>
32+
<Card rounded raised={False}>
33+
<h1>Card title</h1>
34+
<p>Card content</p>
35+
</Card>
36+
</div>
37+
)

examples/table_codec/__init__.py

Whitespace-only changes.
File renamed without changes.
File renamed without changes.

examples/table_import_hook/__init__.py

Whitespace-only changes.

examples/table_import_hook/main.py

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from pyjsx import auto_setup
2+
3+
from table import make_table
4+
5+
6+
print(make_table())

examples/table_import_hook/table.px

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
from pyjsx import jsx, JSX
2+
3+
4+
def make_header(names: list[str]) -> JSX:
5+
return (
6+
<thead>
7+
<tr>
8+
{<th>{name}</th> for name in names}
9+
</tr>
10+
</thead>
11+
)
12+
13+
14+
def make_body(rows: list[list[str]]) -> JSX:
15+
return (
16+
<tbody>
17+
{
18+
<tr>
19+
{<td>{cell}</td> for cell in row}
20+
</tr>
21+
for row in rows
22+
}
23+
</tbody>
24+
)
25+
26+
27+
def make_table() -> JSX:
28+
columns = ["Name", "Age"]
29+
rows = [["Alice", "34"], ["Bob", "56"]]
30+
31+
return (
32+
<table>
33+
{make_header(columns)}
34+
{make_body(rows)}
35+
</table>
36+
)

pyjsx/import_hook.py

+22-11
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,10 @@
2323
from pyjsx.transpiler import transpile
2424

2525

26-
class PyJSXLoader(FileLoader):
27-
def __init__(self, name: str):
28-
self.name = name
29-
self.path = f"{name}.px"
26+
PYJSX_SUFFIX = ".px"
27+
3028

29+
class PyJSXLoader(FileLoader):
3130
def _compile(self) -> str:
3231
return transpile(Path(self.path).read_text("utf-8"))
3332

@@ -46,14 +45,26 @@ def find_spec(
4645
path: Sequence[str] | None,
4746
target: ModuleType | None = None, # noqa: ARG002
4847
) -> ModuleSpec | None:
49-
filename = f"{fullname}.px"
50-
if not Path(filename).exists():
51-
return None
52-
if path:
53-
msg = "Only top-level imports are supported"
54-
raise NotImplementedError(msg)
55-
return importlib.util.spec_from_loader(fullname, PyJSXLoader(fullname))
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+
def _spec_from_path(self, fullname: str, path: str) -> ModuleSpec | None:
56+
last_segment = fullname.rsplit(".", maxsplit=1)[-1]
57+
full_path = Path(path) / f"{last_segment}{PYJSX_SUFFIX}"
58+
if full_path.exists():
59+
loader = PyJSXLoader(fullname, str(full_path))
60+
return importlib.util.spec_from_loader(fullname, loader)
5661

5762

5863
def register_import_hook() -> None:
64+
"""Register import hook for .px files."""
5965
sys.meta_path.append(PyJSXFinder())
66+
67+
68+
def unregister_import_hook() -> None:
69+
"""Unregister import hook for .px files."""
70+
sys.meta_path = [finder for finder in sys.meta_path if not isinstance(finder, PyJSXFinder)]
File renamed without changes.

tests/test_examples.py

+9-6
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,18 @@ def run_example(name: str):
1313

1414

1515
@pytest.mark.parametrize(
16-
"example",
16+
("example", "loader"),
1717
[
18-
"table",
19-
"props",
20-
"custom",
18+
("table", "codec"),
19+
("table", "import_hook"),
20+
("props", "codec"),
21+
("props", "import_hook"),
22+
("custom_components", "codec"),
23+
("custom_components", "import_hook"),
2124
],
2225
)
23-
def test_example(request, snapshot, example):
26+
def test_example(snapshot, example, loader):
2427
snapshot.snapshot_dir = Path(__file__).parent / "data"
2528
snapshot.assert_match(
26-
run_example(example), f"examples-{request.node.callspec.id}.txt"
29+
run_example(f"{example}_{loader}"), f"examples-{example}.txt"
2730
)

tests/test_import_hook.py

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
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[reportAttributeAccessIssue]
26+
27+
assert str(main.hello()) == """\
28+
<h1>
29+
Hello, World!
30+
</h1>"""

tests/test_module/__init__.py

Whitespace-only changes.

tests/test_module/main.px

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
from pyjsx import jsx
2+
3+
def hello():
4+
return <h1>Hello, World!</h1>

0 commit comments

Comments
 (0)