pyponent 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pyponent-0.1.0/LICENSE +21 -0
- pyponent-0.1.0/PKG-INFO +196 -0
- pyponent-0.1.0/README.md +167 -0
- pyponent-0.1.0/pyproject.toml +59 -0
- pyponent-0.1.0/setup.cfg +4 -0
- pyponent-0.1.0/src/pyponent/__init__.py +1 -0
- pyponent-0.1.0/src/pyponent/core.py +86 -0
- pyponent-0.1.0/src/pyponent/diff.py +36 -0
- pyponent-0.1.0/src/pyponent/hooks.py +85 -0
- pyponent-0.1.0/src/pyponent/html.py +68 -0
- pyponent-0.1.0/src/pyponent/router.py +44 -0
- pyponent-0.1.0/src/pyponent/web.py +348 -0
- pyponent-0.1.0/src/pyponent.egg-info/PKG-INFO +196 -0
- pyponent-0.1.0/src/pyponent.egg-info/SOURCES.txt +17 -0
- pyponent-0.1.0/src/pyponent.egg-info/dependency_links.txt +1 -0
- pyponent-0.1.0/src/pyponent.egg-info/requires.txt +12 -0
- pyponent-0.1.0/src/pyponent.egg-info/top_level.txt +1 -0
- pyponent-0.1.0/tests/test_core.py +14 -0
- pyponent-0.1.0/tests/test_diff.py +21 -0
pyponent-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Casey Dale Siatong
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pyponent-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyponent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A blazing-fast, Server-Driven UI framework for Python using FastAPI and WebSockets.
|
|
5
|
+
Author-email: Casey Dale Siatong <daledev07@gmail.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/DaleStack/Pyponent
|
|
7
|
+
Project-URL: Bug Tracker, https://github.com/DaleStack/Pyponent/issues
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Classifier: Topic :: Software Development :: User Interfaces
|
|
12
|
+
Classifier: Topic :: Internet :: WWW/HTTP :: HTTP Servers
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Requires-Python: >=3.13
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Requires-Dist: build>=1.4.0
|
|
18
|
+
Requires-Dist: fastapi>=0.132.0
|
|
19
|
+
Requires-Dist: pytest>=9.0.2
|
|
20
|
+
Requires-Dist: uvicorn[standard]>=0.41.0
|
|
21
|
+
Requires-Dist: watchfiles>=1.1.1
|
|
22
|
+
Requires-Dist: websocket>=0.2.1
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest>=7.0; extra == "dev"
|
|
25
|
+
Requires-Dist: ruff>=0.3.0; extra == "dev"
|
|
26
|
+
Requires-Dist: build; extra == "dev"
|
|
27
|
+
Requires-Dist: twine; extra == "dev"
|
|
28
|
+
Dynamic: license-file
|
|
29
|
+
|
|
30
|
+
# Pyponent
|
|
31
|
+
|
|
32
|
+
**Pyponent** is a blazing-fast, Server-Driven UI (SDUI) framework for Python. It allows you to build highly interactive, real-time Single Page Applications (SPAs) entirely in Python—without writing a single line of JavaScript, HTML, or CSS (unless you want to).
|
|
33
|
+
|
|
34
|
+
Powered by **FastAPI** and **WebSockets**, Pyponent manages state on the server, calculates Virtual DOM diffs in pure Python, and surgically updates the browser using granular JSON patches.
|
|
35
|
+
|
|
36
|
+
---
|
|
37
|
+
|
|
38
|
+
## Why was Pyponent made?
|
|
39
|
+
Modern web development often requires massive context switching. To build a dynamic web app, developers usually have to write Python for the backend, design APIs, configure Webpack/Vite, and write JavaScript/React for the frontend.
|
|
40
|
+
|
|
41
|
+
**Pyponent was built to eliminate the frontend build step.** It is designed for Python developers who want to build complex, interactive dashboards and tools at the speed of thought, keeping their data, state, and UI logic perfectly synced in one unified codebase.
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Pros and Cons
|
|
48
|
+
|
|
49
|
+
### Pros
|
|
50
|
+
* **Zero JavaScript:** Build modals, interactive forms, and real-time dashboards using pure Python.
|
|
51
|
+
* **Granular DOM Diffing:** Unlike older frameworks that reload the whole page, Pyponent sends tiny JSON patches over WebSockets, preserving cursor focus and scrolling.
|
|
52
|
+
* **Built-in SPA Routing:** Includes a client-side router (`Router` and `Link`) for instant, zero-refresh page navigation.
|
|
53
|
+
* **Native Tailwind Support:** Turn on Tailwind CSS with a single boolean flag.
|
|
54
|
+
* **Secure by Default:** Because state lives on the server, sensitive business logic and API keys are never exposed to the browser.
|
|
55
|
+
|
|
56
|
+
### Cons
|
|
57
|
+
* **Requires Always-On Connection:** Pyponent relies entirely on WebSockets. It does not work offline.
|
|
58
|
+
* **Network Latency:** Every button click travels to the server and back. It is incredibly fast, but not suited for high-frequency client-side animations (like 60fps drag-and-drop or WebGL games).
|
|
59
|
+
* **State Management Scale:** Because the server holds the state for every connected user, massive applications with millions of concurrent users will require careful load balancing.
|
|
60
|
+
|
|
61
|
+
---
|
|
62
|
+
|
|
63
|
+
## How it Works
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
1. **Initial Load:** You define components in Python. Pyponent renders them to a static HTML string and sends it to the browser.
|
|
68
|
+
2. **WebSocket Connection:** The browser connects back to the FastAPI server via WebSockets.
|
|
69
|
+
3. **Interactivity:** When a user clicks a button or types in an input, the browser sends a tiny JSON event `{"event_name": "onClick", "target_id": "btn-1"}` to the server.
|
|
70
|
+
4. **Reconciliation:** Python updates the state, rebuilds the Virtual DOM in memory, and calculates the exact differences.
|
|
71
|
+
5. **Patching:** Python sends a surgical JSON patch back to the browser, updating only the elements that changed.
|
|
72
|
+
|
|
73
|
+
---
|
|
74
|
+
|
|
75
|
+
## Installation & Quick Start
|
|
76
|
+
|
|
77
|
+
*(Note: Pyponent is designed to run alongside Uvicorn and FastAPI).*
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
pip install pyponent fastapi uvicorn
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## The "Hello World" App
|
|
84
|
+
|
|
85
|
+
Create a file named `main.py`
|
|
86
|
+
```python
|
|
87
|
+
from fastapi import FastAPI
|
|
88
|
+
from pyponent.web import setup_pyponent, run
|
|
89
|
+
from pyponent.html import div, h1, p
|
|
90
|
+
|
|
91
|
+
def App(**props):
|
|
92
|
+
return div(
|
|
93
|
+
h1("Hello, Pyponent!"),
|
|
94
|
+
p("This is a pure Python UI.")
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
app = FastAPI()
|
|
98
|
+
|
|
99
|
+
# Attach Pyponent to your FastAPI app
|
|
100
|
+
setup_pyponent(app, App, title="My First App")
|
|
101
|
+
|
|
102
|
+
if __name__ == "__main__":
|
|
103
|
+
run("main:app", port=8000, reload=True)
|
|
104
|
+
```
|
|
105
|
+
Run it via terminal: `python main.py` or `uv run main.py`
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
## Core Features
|
|
109
|
+
|
|
110
|
+
### 1. State & Interactivity (`use_state`)
|
|
111
|
+
Add interactivity without JavaScript. Just bind state to your HTML elements.
|
|
112
|
+
```python
|
|
113
|
+
from pyponent.hooks import use_state
|
|
114
|
+
from pyponent.html import div, h2, button
|
|
115
|
+
|
|
116
|
+
def Counter(**props):
|
|
117
|
+
count, set_count = use_state(0)
|
|
118
|
+
|
|
119
|
+
return div(
|
|
120
|
+
h2(f"Clicks: {count}"),
|
|
121
|
+
button("Increment", onClick=lambda e: set_count(count + 1))
|
|
122
|
+
)
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### 2. Live Inputs (No Cursor Loss!)
|
|
126
|
+
Because of Pyponent's surgical diffing engine, you can type into inputs without the DOM reloading.
|
|
127
|
+
|
|
128
|
+
⚠️ Golden Rule: Always provide a hardcoded id for text inputs!
|
|
129
|
+
```python
|
|
130
|
+
from pyponent.hooks import use_state
|
|
131
|
+
from pyponent.html import div, input_, p
|
|
132
|
+
|
|
133
|
+
def LiveMirror(**props):
|
|
134
|
+
text, set_text = use_state("")
|
|
135
|
+
|
|
136
|
+
return div(
|
|
137
|
+
input_(
|
|
138
|
+
id="mirror-input", # Required!
|
|
139
|
+
type="text",
|
|
140
|
+
value=text,
|
|
141
|
+
onInput=lambda e: set_text(e.get("value", ""))
|
|
142
|
+
),
|
|
143
|
+
p("You typed: ", text)
|
|
144
|
+
)
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
### 3. Tailwind & Custom Head Tags
|
|
148
|
+
Pyponent makes styling effortless. You can inject multiple `meta`, `link`, or `script` tags safely using a list, and opt into Tailwind CSS with a single flag.
|
|
149
|
+
```python
|
|
150
|
+
from pyponent.hooks import use_state
|
|
151
|
+
from pyponent.html import div, input_, p
|
|
152
|
+
from pyponent.web import setup_pyponent
|
|
153
|
+
|
|
154
|
+
my_seo_tags = [
|
|
155
|
+
'<meta name="description" content="A blazing fast Pyponent App.">',
|
|
156
|
+
'styles/sample.css', # Use styles/ directory, it has automatic injection
|
|
157
|
+
'<script src="[https://js.stripe.com/v3/](https://js.stripe.com/v3/)"></script>'
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
setup_pyponent(
|
|
161
|
+
app,
|
|
162
|
+
App,
|
|
163
|
+
title="My E-Commerce Dashboard",
|
|
164
|
+
use_tailwind=True, # Instantly activates Tailwind CDN!
|
|
165
|
+
meta_tags=my_seo_tags
|
|
166
|
+
)
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
### 4. Zero Refresh Routing
|
|
170
|
+
Build Multi-Page Applications without page reloads using the built-in router.
|
|
171
|
+
```python
|
|
172
|
+
from pyponent.router import Router, Link
|
|
173
|
+
|
|
174
|
+
def Navigation(**props):
|
|
175
|
+
return div(
|
|
176
|
+
# Use Link for internal SPA navigation and a tag for external
|
|
177
|
+
Link(to="/", children=["Home"]),
|
|
178
|
+
Link(to="/dashboard", children=["Dashboard"])
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
def App(**props):
|
|
182
|
+
return div(
|
|
183
|
+
Navigation(),
|
|
184
|
+
Router(
|
|
185
|
+
initial_path=props.get("initial_path", "/"),
|
|
186
|
+
routes={
|
|
187
|
+
"/": HomePage,
|
|
188
|
+
"/dashboard": DashboardPage
|
|
189
|
+
}
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
## License
|
|
195
|
+
|
|
196
|
+
Pyponent is proudly open-source and is licensed under the [MIT License](LICENSE). You are free to use it in personal, open-source, and commercial projects.
|
pyponent-0.1.0/README.md
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# Pyponent
|
|
2
|
+
|
|
3
|
+
**Pyponent** is a blazing-fast, Server-Driven UI (SDUI) framework for Python. It allows you to build highly interactive, real-time Single Page Applications (SPAs) entirely in Python—without writing a single line of JavaScript, HTML, or CSS (unless you want to).
|
|
4
|
+
|
|
5
|
+
Powered by **FastAPI** and **WebSockets**, Pyponent manages state on the server, calculates Virtual DOM diffs in pure Python, and surgically updates the browser using granular JSON patches.
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## Why was Pyponent made?
|
|
10
|
+
Modern web development often requires massive context switching. To build a dynamic web app, developers usually have to write Python for the backend, design APIs, configure Webpack/Vite, and write JavaScript/React for the frontend.
|
|
11
|
+
|
|
12
|
+
**Pyponent was built to eliminate the frontend build step.** It is designed for Python developers who want to build complex, interactive dashboards and tools at the speed of thought, keeping their data, state, and UI logic perfectly synced in one unified codebase.
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Pros and Cons
|
|
19
|
+
|
|
20
|
+
### Pros
|
|
21
|
+
* **Zero JavaScript:** Build modals, interactive forms, and real-time dashboards using pure Python.
|
|
22
|
+
* **Granular DOM Diffing:** Unlike older frameworks that reload the whole page, Pyponent sends tiny JSON patches over WebSockets, preserving cursor focus and scrolling.
|
|
23
|
+
* **Built-in SPA Routing:** Includes a client-side router (`Router` and `Link`) for instant, zero-refresh page navigation.
|
|
24
|
+
* **Native Tailwind Support:** Turn on Tailwind CSS with a single boolean flag.
|
|
25
|
+
* **Secure by Default:** Because state lives on the server, sensitive business logic and API keys are never exposed to the browser.
|
|
26
|
+
|
|
27
|
+
### Cons
|
|
28
|
+
* **Requires Always-On Connection:** Pyponent relies entirely on WebSockets. It does not work offline.
|
|
29
|
+
* **Network Latency:** Every button click travels to the server and back. It is incredibly fast, but not suited for high-frequency client-side animations (like 60fps drag-and-drop or WebGL games).
|
|
30
|
+
* **State Management Scale:** Because the server holds the state for every connected user, massive applications with millions of concurrent users will require careful load balancing.
|
|
31
|
+
|
|
32
|
+
---
|
|
33
|
+
|
|
34
|
+
## How it Works
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
1. **Initial Load:** You define components in Python. Pyponent renders them to a static HTML string and sends it to the browser.
|
|
39
|
+
2. **WebSocket Connection:** The browser connects back to the FastAPI server via WebSockets.
|
|
40
|
+
3. **Interactivity:** When a user clicks a button or types in an input, the browser sends a tiny JSON event `{"event_name": "onClick", "target_id": "btn-1"}` to the server.
|
|
41
|
+
4. **Reconciliation:** Python updates the state, rebuilds the Virtual DOM in memory, and calculates the exact differences.
|
|
42
|
+
5. **Patching:** Python sends a surgical JSON patch back to the browser, updating only the elements that changed.
|
|
43
|
+
|
|
44
|
+
---
|
|
45
|
+
|
|
46
|
+
## Installation & Quick Start
|
|
47
|
+
|
|
48
|
+
*(Note: Pyponent is designed to run alongside Uvicorn and FastAPI).*
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install pyponent fastapi uvicorn
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## The "Hello World" App
|
|
55
|
+
|
|
56
|
+
Create a file named `main.py`
|
|
57
|
+
```python
|
|
58
|
+
from fastapi import FastAPI
|
|
59
|
+
from pyponent.web import setup_pyponent, run
|
|
60
|
+
from pyponent.html import div, h1, p
|
|
61
|
+
|
|
62
|
+
def App(**props):
|
|
63
|
+
return div(
|
|
64
|
+
h1("Hello, Pyponent!"),
|
|
65
|
+
p("This is a pure Python UI.")
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
app = FastAPI()
|
|
69
|
+
|
|
70
|
+
# Attach Pyponent to your FastAPI app
|
|
71
|
+
setup_pyponent(app, App, title="My First App")
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
run("main:app", port=8000, reload=True)
|
|
75
|
+
```
|
|
76
|
+
Run it via terminal: `python main.py` or `uv run main.py`
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
## Core Features
|
|
80
|
+
|
|
81
|
+
### 1. State & Interactivity (`use_state`)
|
|
82
|
+
Add interactivity without JavaScript. Just bind state to your HTML elements.
|
|
83
|
+
```python
|
|
84
|
+
from pyponent.hooks import use_state
|
|
85
|
+
from pyponent.html import div, h2, button
|
|
86
|
+
|
|
87
|
+
def Counter(**props):
|
|
88
|
+
count, set_count = use_state(0)
|
|
89
|
+
|
|
90
|
+
return div(
|
|
91
|
+
h2(f"Clicks: {count}"),
|
|
92
|
+
button("Increment", onClick=lambda e: set_count(count + 1))
|
|
93
|
+
)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
### 2. Live Inputs (No Cursor Loss!)
|
|
97
|
+
Because of Pyponent's surgical diffing engine, you can type into inputs without the DOM reloading.
|
|
98
|
+
|
|
99
|
+
⚠️ Golden Rule: Always provide a hardcoded id for text inputs!
|
|
100
|
+
```python
|
|
101
|
+
from pyponent.hooks import use_state
|
|
102
|
+
from pyponent.html import div, input_, p
|
|
103
|
+
|
|
104
|
+
def LiveMirror(**props):
|
|
105
|
+
text, set_text = use_state("")
|
|
106
|
+
|
|
107
|
+
return div(
|
|
108
|
+
input_(
|
|
109
|
+
id="mirror-input", # Required!
|
|
110
|
+
type="text",
|
|
111
|
+
value=text,
|
|
112
|
+
onInput=lambda e: set_text(e.get("value", ""))
|
|
113
|
+
),
|
|
114
|
+
p("You typed: ", text)
|
|
115
|
+
)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### 3. Tailwind & Custom Head Tags
|
|
119
|
+
Pyponent makes styling effortless. You can inject multiple `meta`, `link`, or `script` tags safely using a list, and opt into Tailwind CSS with a single flag.
|
|
120
|
+
```python
|
|
121
|
+
from pyponent.hooks import use_state
|
|
122
|
+
from pyponent.html import div, input_, p
|
|
123
|
+
from pyponent.web import setup_pyponent
|
|
124
|
+
|
|
125
|
+
my_seo_tags = [
|
|
126
|
+
'<meta name="description" content="A blazing fast Pyponent App.">',
|
|
127
|
+
'styles/sample.css', # Use styles/ directory, it has automatic injection
|
|
128
|
+
'<script src="[https://js.stripe.com/v3/](https://js.stripe.com/v3/)"></script>'
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
setup_pyponent(
|
|
132
|
+
app,
|
|
133
|
+
App,
|
|
134
|
+
title="My E-Commerce Dashboard",
|
|
135
|
+
use_tailwind=True, # Instantly activates Tailwind CDN!
|
|
136
|
+
meta_tags=my_seo_tags
|
|
137
|
+
)
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
### 4. Zero Refresh Routing
|
|
141
|
+
Build Multi-Page Applications without page reloads using the built-in router.
|
|
142
|
+
```python
|
|
143
|
+
from pyponent.router import Router, Link
|
|
144
|
+
|
|
145
|
+
def Navigation(**props):
|
|
146
|
+
return div(
|
|
147
|
+
# Use Link for internal SPA navigation and a tag for external
|
|
148
|
+
Link(to="/", children=["Home"]),
|
|
149
|
+
Link(to="/dashboard", children=["Dashboard"])
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def App(**props):
|
|
153
|
+
return div(
|
|
154
|
+
Navigation(),
|
|
155
|
+
Router(
|
|
156
|
+
initial_path=props.get("initial_path", "/"),
|
|
157
|
+
routes={
|
|
158
|
+
"/": HomePage,
|
|
159
|
+
"/dashboard": DashboardPage
|
|
160
|
+
}
|
|
161
|
+
)
|
|
162
|
+
)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## License
|
|
166
|
+
|
|
167
|
+
Pyponent is proudly open-source and is licensed under the [MIT License](LICENSE). You are free to use it in personal, open-source, and commercial projects.
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pyponent"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "A blazing-fast, Server-Driven UI framework for Python using FastAPI and WebSockets."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.13"
|
|
11
|
+
|
|
12
|
+
authors = [
|
|
13
|
+
{ name = "Casey Dale Siatong", email = "daledev07@gmail.com" },
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Topic :: Software Development :: User Interfaces",
|
|
21
|
+
"Topic :: Internet :: WWW/HTTP :: HTTP Servers",
|
|
22
|
+
"Intended Audience :: Developers",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
dependencies = [
|
|
26
|
+
"build>=1.4.0",
|
|
27
|
+
"fastapi>=0.132.0",
|
|
28
|
+
"pytest>=9.0.2",
|
|
29
|
+
"uvicorn[standard]>=0.41.0",
|
|
30
|
+
"watchfiles>=1.1.1",
|
|
31
|
+
"websocket>=0.2.1",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.optional-dependencies]
|
|
35
|
+
dev = [
|
|
36
|
+
"pytest>=7.0",
|
|
37
|
+
"ruff>=0.3.0",
|
|
38
|
+
"build",
|
|
39
|
+
"twine"
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[tool.ruff]
|
|
43
|
+
line-length = 88 # The standard Black line length
|
|
44
|
+
target-version = "py39"
|
|
45
|
+
|
|
46
|
+
[tool.ruff.lint]
|
|
47
|
+
# E = pycodestyle (standard formatting rules)
|
|
48
|
+
# F = pyflakes (catches unused imports, undefined variables)
|
|
49
|
+
# I = isort (automatically sorts your imports alphabetically)
|
|
50
|
+
select = ["E", "F", "I"]
|
|
51
|
+
|
|
52
|
+
[project.urls]
|
|
53
|
+
"Homepage" = "https://github.com/DaleStack/Pyponent"
|
|
54
|
+
"Bug Tracker" = "https://github.com/DaleStack/Pyponent/issues"
|
|
55
|
+
|
|
56
|
+
[tool.pytest.ini_options]
|
|
57
|
+
pythonpath = [
|
|
58
|
+
"src"
|
|
59
|
+
]
|
pyponent-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import uuid
|
|
2
|
+
from dataclasses import dataclass, field
|
|
3
|
+
from typing import Any, Callable, Dict, List, Union
|
|
4
|
+
|
|
5
|
+
from .hooks import dispatcher_context
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class VNode:
|
|
10
|
+
tag: Union[str, Callable]
|
|
11
|
+
props: Dict[str, Any] = field(default_factory=dict)
|
|
12
|
+
children: List[Union["VNode", str]] = field(default_factory=list)
|
|
13
|
+
id: str = field(init=False) # Tell dataclass we will set this ourselves
|
|
14
|
+
|
|
15
|
+
def __post_init__(self):
|
|
16
|
+
# Guarantee an ID for every element for the Diffing Engine
|
|
17
|
+
if "id" not in self.props:
|
|
18
|
+
self.props["id"] = f"pyp-{uuid.uuid4().hex[:8]}"
|
|
19
|
+
self.id = self.props["id"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def render_to_string(node: Union[VNode, str]) -> str:
|
|
23
|
+
if isinstance(node, str):
|
|
24
|
+
return node
|
|
25
|
+
|
|
26
|
+
html_attrs = []
|
|
27
|
+
for key, value in node.props.items():
|
|
28
|
+
if not key.startswith("on"):
|
|
29
|
+
html_attrs.append(f'{key}="{value}"')
|
|
30
|
+
|
|
31
|
+
attr_str = " " + " ".join(html_attrs) if html_attrs else ""
|
|
32
|
+
children_str = "".join(render_to_string(child) for child in node.children)
|
|
33
|
+
return f"<{node.tag}{attr_str}>{children_str}</{node.tag}>"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def fire_event(
|
|
37
|
+
node: Union["VNode", str], target_id: str, event_name: str, event_data: dict = None
|
|
38
|
+
) -> bool:
|
|
39
|
+
if isinstance(node, str):
|
|
40
|
+
return False
|
|
41
|
+
|
|
42
|
+
if node.props.get("id") == target_id:
|
|
43
|
+
handler = node.props.get(event_name)
|
|
44
|
+
if handler:
|
|
45
|
+
try:
|
|
46
|
+
# Pass the typing payload to the user's function
|
|
47
|
+
handler(event_data or {})
|
|
48
|
+
except TypeError:
|
|
49
|
+
# If they didn't ask for the payload (like a simple button click),
|
|
50
|
+
# run it empty
|
|
51
|
+
handler()
|
|
52
|
+
return True
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
for child in node.children:
|
|
56
|
+
if fire_event(child, target_id, event_name, event_data):
|
|
57
|
+
return True
|
|
58
|
+
|
|
59
|
+
return False
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def resolve_vdom(node: Union[VNode, str], path: str = "root") -> Union[VNode, str]:
|
|
63
|
+
if isinstance(node, str):
|
|
64
|
+
return node
|
|
65
|
+
|
|
66
|
+
if callable(node.tag):
|
|
67
|
+
node_id = f"{node.tag.__name__}_{path}"
|
|
68
|
+
current_dispatcher = dispatcher_context.get()
|
|
69
|
+
current_dispatcher.prepare_render(node_id)
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
# Use ** to unpack the dictionary into keyword arguments
|
|
73
|
+
resolved_component = node.tag(**node.props)
|
|
74
|
+
except TypeError:
|
|
75
|
+
resolved_component = node.tag()
|
|
76
|
+
|
|
77
|
+
return resolve_vdom(resolved_component, path)
|
|
78
|
+
|
|
79
|
+
resolved_children = []
|
|
80
|
+
for index, child in enumerate(node.children):
|
|
81
|
+
if child is None:
|
|
82
|
+
continue
|
|
83
|
+
child_path = f"{path}.{index}"
|
|
84
|
+
resolved_children.append(resolve_vdom(child, child_path))
|
|
85
|
+
|
|
86
|
+
return VNode(tag=node.tag, props=node.props, children=resolved_children)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from .core import VNode, render_to_string
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def diff_vdom(old, new):
|
|
5
|
+
patches = []
|
|
6
|
+
|
|
7
|
+
if old.tag != new.tag:
|
|
8
|
+
return [{"type": "replace", "id": old.id, "html": render_to_string(new)}]
|
|
9
|
+
|
|
10
|
+
new.id = old.id
|
|
11
|
+
new.props["id"] = old.id
|
|
12
|
+
|
|
13
|
+
# Filter out functions before diffing and sending
|
|
14
|
+
safe_old_props = {
|
|
15
|
+
k: v for k, v in old.props.items() if not callable(v) and not k.startswith("on")
|
|
16
|
+
}
|
|
17
|
+
safe_new_props = {
|
|
18
|
+
k: v for k, v in new.props.items() if not callable(v) and not k.startswith("on")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if safe_old_props != safe_new_props:
|
|
22
|
+
patches.append({"type": "props", "id": new.id, "props": safe_new_props})
|
|
23
|
+
|
|
24
|
+
if len(old.children) != len(new.children):
|
|
25
|
+
inner_html = "".join(render_to_string(c) for c in new.children)
|
|
26
|
+
patches.append({"type": "inner_html", "id": new.id, "html": inner_html})
|
|
27
|
+
else:
|
|
28
|
+
for o_child, n_child in zip(old.children, new.children):
|
|
29
|
+
if isinstance(o_child, VNode) and isinstance(n_child, VNode):
|
|
30
|
+
patches.extend(diff_vdom(o_child, n_child))
|
|
31
|
+
elif o_child != n_child:
|
|
32
|
+
inner_html = "".join(render_to_string(c) for c in new.children)
|
|
33
|
+
patches.append({"type": "inner_html", "id": new.id, "html": inner_html})
|
|
34
|
+
break
|
|
35
|
+
|
|
36
|
+
return patches
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import contextvars
|
|
2
|
+
import threading
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class Dispatcher:
|
|
6
|
+
def __init__(self):
|
|
7
|
+
self.states = {}
|
|
8
|
+
self.hook_indices = {}
|
|
9
|
+
self.current_node_id = None
|
|
10
|
+
self.trigger_render = None
|
|
11
|
+
|
|
12
|
+
# Effect Tracking
|
|
13
|
+
|
|
14
|
+
# Stores previous dependencies: {node_id: [deps_1, deps_2]}
|
|
15
|
+
self.effects = {}
|
|
16
|
+
# Tracks the cursor for effects
|
|
17
|
+
self.effect_indices = {}
|
|
18
|
+
# A queue of effects to run AFTER the render is complete
|
|
19
|
+
self.pending_effects = []
|
|
20
|
+
|
|
21
|
+
def prepare_render(self, node_id: str):
|
|
22
|
+
self.current_node_id = node_id
|
|
23
|
+
self.hook_indices[node_id] = 0
|
|
24
|
+
self.effect_indices[node_id] = 0 # Reset effect cursor
|
|
25
|
+
|
|
26
|
+
if node_id not in self.states:
|
|
27
|
+
self.states[node_id] = []
|
|
28
|
+
if node_id not in self.effects:
|
|
29
|
+
self.effects[node_id] = []
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
dispatcher_context: contextvars.ContextVar[Dispatcher] = contextvars.ContextVar(
|
|
33
|
+
"dispatcher"
|
|
34
|
+
) # noqa: E501
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def use_state(initial_value):
|
|
38
|
+
dispatcher = dispatcher_context.get()
|
|
39
|
+
node_id = dispatcher.current_node_id
|
|
40
|
+
idx = dispatcher.hook_indices[node_id]
|
|
41
|
+
|
|
42
|
+
if len(dispatcher.states[node_id]) == idx:
|
|
43
|
+
dispatcher.states[node_id].append(initial_value)
|
|
44
|
+
|
|
45
|
+
def set_state(new_value):
|
|
46
|
+
dispatcher.states[node_id][idx] = new_value
|
|
47
|
+
if dispatcher.trigger_render:
|
|
48
|
+
dispatcher.trigger_render()
|
|
49
|
+
|
|
50
|
+
value = dispatcher.states[node_id][idx]
|
|
51
|
+
dispatcher.hook_indices[node_id] += 1
|
|
52
|
+
return value, set_state
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# The use_effect hook
|
|
56
|
+
def use_effect(callback, deps=None):
|
|
57
|
+
dispatcher = dispatcher_context.get()
|
|
58
|
+
node_id = dispatcher.current_node_id
|
|
59
|
+
idx = dispatcher.effect_indices[node_id]
|
|
60
|
+
|
|
61
|
+
# If this is the very first render for this effect
|
|
62
|
+
if len(dispatcher.effects[node_id]) == idx:
|
|
63
|
+
dispatcher.effects[node_id].append(deps)
|
|
64
|
+
dispatcher.pending_effects.append(callback)
|
|
65
|
+
else:
|
|
66
|
+
# Compare previous dependencies with the new ones
|
|
67
|
+
prev_deps = dispatcher.effects[node_id][idx]
|
|
68
|
+
|
|
69
|
+
# If deps is None, it runs every single render.
|
|
70
|
+
# If deps changed, we update the stored deps and queue the effect.
|
|
71
|
+
if deps is None or prev_deps != deps:
|
|
72
|
+
dispatcher.effects[node_id][idx] = deps
|
|
73
|
+
dispatcher.pending_effects.append(callback)
|
|
74
|
+
|
|
75
|
+
dispatcher.effect_indices[node_id] += 1
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def use_async_effect(callback, deps=None):
|
|
79
|
+
"""A custom hook that automatically runs the callback in a background thread."""
|
|
80
|
+
|
|
81
|
+
def thread_runner():
|
|
82
|
+
threading.Thread(target=callback).start()
|
|
83
|
+
|
|
84
|
+
# We pass our thread_runner into the standard use_effect
|
|
85
|
+
use_effect(thread_runner, deps)
|