htag 2.0.0__tar.gz → 2.0.2__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.
- {htag-2.0.0 → htag-2.0.2}/PKG-INFO +38 -16
- {htag-2.0.0 → htag-2.0.2}/README.md +36 -14
- {htag-2.0.0 → htag-2.0.2}/htag/cli.py +1 -1
- {htag-2.0.0 → htag-2.0.2}/htag/client_js.py +48 -15
- {htag-2.0.0 → htag-2.0.2}/htag/core.py +38 -8
- {htag-2.0.0 → htag-2.0.2}/htag/runner.py +12 -6
- {htag-2.0.0 → htag-2.0.2}/htag/runners/chromeapp.py +2 -1
- {htag-2.0.0 → htag-2.0.2}/htag/web.py +2 -6
- {htag-2.0.0 → htag-2.0.2}/htag.egg-info/PKG-INFO +38 -16
- {htag-2.0.0 → htag-2.0.2}/htag.egg-info/SOURCES.txt +1 -0
- {htag-2.0.0 → htag-2.0.2}/pyproject.toml +3 -2
- htag-2.0.2/tests/test_deprecation_warnings.py +78 -0
- htag-2.0.2/tests/test_simple_events.py +190 -0
- htag-2.0.0/tests/test_simple_events.py +0 -83
- {htag-2.0.0 → htag-2.0.2}/htag/__init__.py +0 -0
- {htag-2.0.0 → htag-2.0.2}/htag/context.py +0 -0
- {htag-2.0.0 → htag-2.0.2}/htag/css.py +0 -0
- {htag-2.0.0 → htag-2.0.2}/htag/exceptions.py +0 -0
- {htag-2.0.0 → htag-2.0.2}/htag/logo.py +0 -0
- {htag-2.0.0 → htag-2.0.2}/htag/runners/__init__.py +0 -0
- {htag-2.0.0 → htag-2.0.2}/htag/runners/pyscript.py +0 -0
- {htag-2.0.0 → htag-2.0.2}/htag/server.py +0 -0
- {htag-2.0.0 → htag-2.0.2}/htag/tag.py +0 -0
- {htag-2.0.0 → htag-2.0.2}/htag/utils.py +0 -0
- {htag-2.0.0 → htag-2.0.2}/htag.egg-info/dependency_links.txt +0 -0
- {htag-2.0.0 → htag-2.0.2}/htag.egg-info/entry_points.txt +0 -0
- {htag-2.0.0 → htag-2.0.2}/htag.egg-info/requires.txt +0 -0
- {htag-2.0.0 → htag-2.0.2}/htag.egg-info/top_level.txt +0 -0
- {htag-2.0.0 → htag-2.0.2}/setup.cfg +0 -0
- {htag-2.0.0 → htag-2.0.2}/tests/test_cmd.py +0 -0
- {htag-2.0.0 → htag-2.0.2}/tests/test_core.py +0 -0
- {htag-2.0.0 → htag-2.0.2}/tests/test_fallback.py +0 -0
- {htag-2.0.0 → htag-2.0.2}/tests/test_lifecycle.py +0 -0
- {htag-2.0.0 → htag-2.0.2}/tests/test_memory.py +0 -0
- {htag-2.0.0 → htag-2.0.2}/tests/test_parano.py +0 -0
- {htag-2.0.0 → htag-2.0.2}/tests/test_reactivity_edge_cases.py +0 -0
- {htag-2.0.0 → htag-2.0.2}/tests/test_runner_pyscript.py +0 -0
- {htag-2.0.0 → htag-2.0.2}/tests/test_runners_reload.py +0 -0
- {htag-2.0.0 → htag-2.0.2}/tests/test_server.py +0 -0
- {htag-2.0.0 → htag-2.0.2}/tests/test_state_advanced.py +0 -0
- {htag-2.0.0 → htag-2.0.2}/tests/test_state_features.py +0 -0
- {htag-2.0.0 → htag-2.0.2}/tests/test_state_proxy.py +0 -0
- {htag-2.0.0 → htag-2.0.2}/tests/test_state_reactivity.py +0 -0
- {htag-2.0.0 → htag-2.0.2}/tests/test_tag_names.py +0 -0
- {htag-2.0.0 → htag-2.0.2}/tests/test_web_sessions.py +0 -0
- {htag-2.0.0 → htag-2.0.2}/tests/test_webapp_run.py +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: htag
|
|
3
|
-
Version: 2.0.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 2.0.2
|
|
4
|
+
Summary: Python3 GUI toolkit for building 'beautiful' applications for mobile, web, and desktop from a single codebase
|
|
5
5
|
Author: manatlan
|
|
6
6
|
License: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/manatlan/htag
|
|
@@ -37,31 +37,46 @@ Requires-Dist: websockets>=16.0
|
|
|
37
37
|
</a>
|
|
38
38
|
</p>
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
**htag** is a Python3 GUI toolkit for building "beautiful" applications for mobile, web, and desktop from a single codebase
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
Here is a full rewrite of **htag 1.0.0**, using only antigravity and prompts/intentions. It's the future of **htag**.
|
|
43
43
|
|
|
44
44
|
Currently, it works ...
|
|
45
45
|
|
|
46
|
-
- For building desktop apps (ChromeApp on linux/windows)
|
|
46
|
+
- For building desktop apps (ChromeApp on linux/windows/mac, or simple WebApp)
|
|
47
47
|
- For building web apps (WebApp as an asgi app (for starlette/fastapi/...))
|
|
48
48
|
- For building SPA HTML Page (with the PyScript runner)
|
|
49
49
|
- For building android apps ([it works](examples/app_android/README.md), **but need to improve**)
|
|
50
50
|
|
|
51
|
+
## Major differences between v1 and v2
|
|
52
|
+
|
|
53
|
+
- A lot simpler & more reactive
|
|
54
|
+
- `Tag.App` is the main app component
|
|
55
|
+
- Full starlette compliant from the ground
|
|
56
|
+
- Websocket communication, if fails : fallback to HTTP SSE
|
|
57
|
+
- No more generic Runner
|
|
58
|
+
- State object for reactivity
|
|
59
|
+
- DX: errors are a lot better handled/viewable, hot-reload available
|
|
60
|
+
- Better events
|
|
61
|
+
- less boilerplate (kiss minded)
|
|
62
|
+
- Styles can be scoped by component
|
|
63
|
+
- ...
|
|
64
|
+
|
|
51
65
|
|
|
52
66
|
## Get Started
|
|
53
67
|
|
|
54
|
-
Check the [Official Documentation](https://manatlan.github.io/htag/) for more information.
|
|
68
|
+
Check the [Official V2 Documentation](https://manatlan.github.io/htag/) for more information. [old v1 docs](https://manatlan.github.io/htag/v1/)
|
|
69
|
+
|
|
55
70
|
|
|
56
71
|
## Install
|
|
57
72
|
|
|
58
73
|
```bash
|
|
59
|
-
uv add htag
|
|
74
|
+
uv add htag -U
|
|
60
75
|
```
|
|
61
76
|
|
|
62
77
|
Or using pip:
|
|
63
78
|
```bash
|
|
64
|
-
pip install htag
|
|
79
|
+
pip install -U htag
|
|
65
80
|
```
|
|
66
81
|
|
|
67
82
|
Alternatively, you can run from source:
|
|
@@ -73,35 +88,42 @@ uv run examples/main3.py
|
|
|
73
88
|
|
|
74
89
|
### Skill
|
|
75
90
|
|
|
76
|
-
With
|
|
91
|
+
With agentic llm, you can use this [SKILL.md](.agent/skills/htag-development/SKILL.md) to create an htag application.
|
|
77
92
|
|
|
78
93
|
|
|
79
94
|
## Antigravity resumes :
|
|
80
95
|
|
|
81
96
|
htag is a Python library for building web applications using HTML, CSS, and JavaScript.
|
|
82
97
|
|
|
83
|
-
###
|
|
98
|
+
### New Features
|
|
84
99
|
* **Zero-Config Hot-Reload**: Passing `reload=True` to any runner (e.g. `ChromeApp(App).run(reload=True)`) automatically watches for Python file changes, seamlessly restarts the backend, and gracefully refreshes the frontend without losing your browser window session.
|
|
85
100
|
* **F5/Reload Robustness**: Refreshing the browser no longer kills the Python backend; the session reconstructs cleanly.
|
|
86
101
|
* **HTTP Fallback (SSE + POST)**: If WebSockets are blocked (e.g. strict proxies) or fail to connect, the client seamlessly falls back to HTTP POST for events and Server-Sent Events (SSE) for receiving UI updates.
|
|
87
102
|
* **Production Debug Mode**: Easily disable error reporting in the client by setting `debug=False` on the runner (e.g. `WebApp(App, debug=False).app`), preventing internal stacktraces from leaking to users.
|
|
88
103
|
* **Parano Mode (Payload Obfuscation)**: By initializing `WebApp(App, parano=True)`, all data exchanged between the frontend and backend is automatically obfuscated using a dynamic XOR cipher and Base64 wrapping, making network traffic unreadable to MITM proxies.
|
|
89
|
-
|
|
90
|
-
### New API Features
|
|
91
104
|
* **`.root`, `.parent`, and `.childs` properties**: Every `GTag` exposes its position in the component tree. `.root` references the main `Tag` instance, `.parent` references the direct parent component, and `.childs` is a list of its children. This allows components to easily navigate the DOM tree and trigger app-level actions.
|
|
92
105
|
* **Declarative UI with Context Managers (`with`)**: You can now build component trees visually using `with` blocks (e.g., `with Tag.div(): Tag.h1("Hello")`), removing the need for `self <= ...` boilerplate.
|
|
93
|
-
* **
|
|
94
|
-
* **Automatic Attribute Normalization**: HTML attributes are normalized to use dashes internally. Setting `self._data_id = "123"` is strictly equivalent to setting `self["data-id"] = "123"`.
|
|
106
|
+
* **Modern Attribute Access (Dictionary Style)**: In addition to `Tag.div(_class="foo")` in constructors, you should now use `self["class"] = "foo"` for dynamic property management. This is the preferred way to handle all attributes, including those with dashes (e.g., `self["data-id"] = 123`).
|
|
95
107
|
* **Reactive State Management (`State`)**: Introducing `State(value)` for automatic UI reactivity. `State` acts as a transparent **Proxy**: you can use comparison/arithmetic operators directly (`if self.count > 0: ...`), mutate nested structures (including `lists`, `dicts`, `sets`, and `tuples`), and delegate attribute assignments seamlessly.
|
|
96
108
|
* **Iterative & Attribute Reactivity**: Iterating over a `State` now yields proxies, meaning mutations like `for item in self.list: item.active = True` trigger re-renders. Attribute assignments (e.g. `self.user.name = "Bob"`) are automatically delegated to the underlying object and notified.
|
|
97
|
-
* **Reactive & Boolean Attributes**: Attributes like `_class`, `_style`, or `_disabled` now support lambdas for dynamic updates. Boolean attributes (e.g. `_disabled=True`) are correctly rendered as key-only or omitted.
|
|
109
|
+
* **Reactive & Boolean Attributes**: Attributes like `_class`, `_style`, or `_disabled` in constructors now support lambdas for dynamic updates. Boolean attributes (e.g. `_disabled=True`) are correctly rendered as key-only or omitted.
|
|
98
110
|
* **Simplified Removal (`.remove()`)**: To remove a component from its parent, simply call `self.remove()` without arguments.
|
|
99
111
|
* **Rapid Content Replacement (`.text`)**: Use the `.text` property on any tag to quickly replace its inner text content without needing to manually clear its children first.
|
|
100
112
|
* **Recursive Statics & JS**: Components created dynamically (via lambdas) now have their `statics` (CSS) and `call_js` commands correctly collected and sent to the client.
|
|
101
113
|
* **Scoped Styles (`styles`)**: Define a `styles` class attribute on any component to get automatically scoped CSS. The framework prefixes every rule with `.htag-ClassName`, handles `@media`, `@keyframes`, pseudo-selectors, and multi-selectors.
|
|
102
114
|
* **CSS Class Helpers**: `add_class()`, `remove_class()`, `toggle_class()`, and `has_class()` for convenient class manipulation without manual string handling.
|
|
103
|
-
* **Simple Events & HashChange**: Support for passing primitive values or custom objects from JS (via `tag["onclick"] = func`
|
|
115
|
+
* **Simple Events & HashChange**: Support for passing primitive values or custom objects from JS (via `tag["onclick"] = func`). Includes built-in support for `onhashchange`.
|
|
104
116
|
* **Fragments (`Tag()`)**: Create virtual components that group children without adding a wrapper tag to the DOM.
|
|
105
117
|
* **Advanced Tag Search (`.find_tag()`)**: Effortlessly locate any component in the tree by its internal htag ID or its manually assigned HTML `id`.
|
|
106
118
|
* **Custom ID Resilience**: Manual HTML `id` attributes are now supported without breaking htag's reactive partial updates, thanks to an automatic `data-htag-id` fallback mechanism.
|
|
107
119
|
* **Automatic Attribute Assignment**: Non-prefixed keyword arguments passed during component instantiation are automatically assigned as instance attributes, simplifying data passing to custom components.
|
|
120
|
+
* **Unified Form Handling**: When a `Tag.form` is submitted, all input fields are automatically collected into a dictionary and passed as `event.value`. You can conveniently access fields using square brackets on the event object (e.g., `e["fieldname"]`).
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
## History
|
|
124
|
+
|
|
125
|
+
At the beginning, there was [guy](https://github.com/manatlan/guy), which was/is the same concept as [python-eel](https://github.com/ChrisKnott/Eel), but more advanced.
|
|
126
|
+
One day, I've discovered [remi](https://github.com/rawpython/remi), and asked my self, if it could be done in a *guy way*. The POC was very good, so I released
|
|
127
|
+
a version of it, named [gtag](https://github.com/manatlan/gtag). It worked well despite some drawbacks, but was too difficult to maintain. So I decided to rewrite all
|
|
128
|
+
from scratch, while staying away from *guy* (to separate, *rendering* and *runners*)... and [htag](https://github.com/manatlan/htag/tree/v1-legacy) was born. The codebase is very short, concepts are better implemented, and it's very easy to maintain. And now (2026) [htag v2](https://github.com/manatlan/htag) is here (a full rewrite of v1 with antigravity) !
|
|
129
|
+
|
|
@@ -16,31 +16,46 @@
|
|
|
16
16
|
</a>
|
|
17
17
|
</p>
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
**htag** is a Python3 GUI toolkit for building "beautiful" applications for mobile, web, and desktop from a single codebase
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
Here is a full rewrite of **htag 1.0.0**, using only antigravity and prompts/intentions. It's the future of **htag**.
|
|
22
22
|
|
|
23
23
|
Currently, it works ...
|
|
24
24
|
|
|
25
|
-
- For building desktop apps (ChromeApp on linux/windows)
|
|
25
|
+
- For building desktop apps (ChromeApp on linux/windows/mac, or simple WebApp)
|
|
26
26
|
- For building web apps (WebApp as an asgi app (for starlette/fastapi/...))
|
|
27
27
|
- For building SPA HTML Page (with the PyScript runner)
|
|
28
28
|
- For building android apps ([it works](examples/app_android/README.md), **but need to improve**)
|
|
29
29
|
|
|
30
|
+
## Major differences between v1 and v2
|
|
31
|
+
|
|
32
|
+
- A lot simpler & more reactive
|
|
33
|
+
- `Tag.App` is the main app component
|
|
34
|
+
- Full starlette compliant from the ground
|
|
35
|
+
- Websocket communication, if fails : fallback to HTTP SSE
|
|
36
|
+
- No more generic Runner
|
|
37
|
+
- State object for reactivity
|
|
38
|
+
- DX: errors are a lot better handled/viewable, hot-reload available
|
|
39
|
+
- Better events
|
|
40
|
+
- less boilerplate (kiss minded)
|
|
41
|
+
- Styles can be scoped by component
|
|
42
|
+
- ...
|
|
43
|
+
|
|
30
44
|
|
|
31
45
|
## Get Started
|
|
32
46
|
|
|
33
|
-
Check the [Official Documentation](https://manatlan.github.io/htag/) for more information.
|
|
47
|
+
Check the [Official V2 Documentation](https://manatlan.github.io/htag/) for more information. [old v1 docs](https://manatlan.github.io/htag/v1/)
|
|
48
|
+
|
|
34
49
|
|
|
35
50
|
## Install
|
|
36
51
|
|
|
37
52
|
```bash
|
|
38
|
-
uv add htag
|
|
53
|
+
uv add htag -U
|
|
39
54
|
```
|
|
40
55
|
|
|
41
56
|
Or using pip:
|
|
42
57
|
```bash
|
|
43
|
-
pip install htag
|
|
58
|
+
pip install -U htag
|
|
44
59
|
```
|
|
45
60
|
|
|
46
61
|
Alternatively, you can run from source:
|
|
@@ -52,35 +67,42 @@ uv run examples/main3.py
|
|
|
52
67
|
|
|
53
68
|
### Skill
|
|
54
69
|
|
|
55
|
-
With
|
|
70
|
+
With agentic llm, you can use this [SKILL.md](.agent/skills/htag-development/SKILL.md) to create an htag application.
|
|
56
71
|
|
|
57
72
|
|
|
58
73
|
## Antigravity resumes :
|
|
59
74
|
|
|
60
75
|
htag is a Python library for building web applications using HTML, CSS, and JavaScript.
|
|
61
76
|
|
|
62
|
-
###
|
|
77
|
+
### New Features
|
|
63
78
|
* **Zero-Config Hot-Reload**: Passing `reload=True` to any runner (e.g. `ChromeApp(App).run(reload=True)`) automatically watches for Python file changes, seamlessly restarts the backend, and gracefully refreshes the frontend without losing your browser window session.
|
|
64
79
|
* **F5/Reload Robustness**: Refreshing the browser no longer kills the Python backend; the session reconstructs cleanly.
|
|
65
80
|
* **HTTP Fallback (SSE + POST)**: If WebSockets are blocked (e.g. strict proxies) or fail to connect, the client seamlessly falls back to HTTP POST for events and Server-Sent Events (SSE) for receiving UI updates.
|
|
66
81
|
* **Production Debug Mode**: Easily disable error reporting in the client by setting `debug=False` on the runner (e.g. `WebApp(App, debug=False).app`), preventing internal stacktraces from leaking to users.
|
|
67
82
|
* **Parano Mode (Payload Obfuscation)**: By initializing `WebApp(App, parano=True)`, all data exchanged between the frontend and backend is automatically obfuscated using a dynamic XOR cipher and Base64 wrapping, making network traffic unreadable to MITM proxies.
|
|
68
|
-
|
|
69
|
-
### New API Features
|
|
70
83
|
* **`.root`, `.parent`, and `.childs` properties**: Every `GTag` exposes its position in the component tree. `.root` references the main `Tag` instance, `.parent` references the direct parent component, and `.childs` is a list of its children. This allows components to easily navigate the DOM tree and trigger app-level actions.
|
|
71
84
|
* **Declarative UI with Context Managers (`with`)**: You can now build component trees visually using `with` blocks (e.g., `with Tag.div(): Tag.h1("Hello")`), removing the need for `self <= ...` boilerplate.
|
|
72
|
-
* **
|
|
73
|
-
* **Automatic Attribute Normalization**: HTML attributes are normalized to use dashes internally. Setting `self._data_id = "123"` is strictly equivalent to setting `self["data-id"] = "123"`.
|
|
85
|
+
* **Modern Attribute Access (Dictionary Style)**: In addition to `Tag.div(_class="foo")` in constructors, you should now use `self["class"] = "foo"` for dynamic property management. This is the preferred way to handle all attributes, including those with dashes (e.g., `self["data-id"] = 123`).
|
|
74
86
|
* **Reactive State Management (`State`)**: Introducing `State(value)` for automatic UI reactivity. `State` acts as a transparent **Proxy**: you can use comparison/arithmetic operators directly (`if self.count > 0: ...`), mutate nested structures (including `lists`, `dicts`, `sets`, and `tuples`), and delegate attribute assignments seamlessly.
|
|
75
87
|
* **Iterative & Attribute Reactivity**: Iterating over a `State` now yields proxies, meaning mutations like `for item in self.list: item.active = True` trigger re-renders. Attribute assignments (e.g. `self.user.name = "Bob"`) are automatically delegated to the underlying object and notified.
|
|
76
|
-
* **Reactive & Boolean Attributes**: Attributes like `_class`, `_style`, or `_disabled` now support lambdas for dynamic updates. Boolean attributes (e.g. `_disabled=True`) are correctly rendered as key-only or omitted.
|
|
88
|
+
* **Reactive & Boolean Attributes**: Attributes like `_class`, `_style`, or `_disabled` in constructors now support lambdas for dynamic updates. Boolean attributes (e.g. `_disabled=True`) are correctly rendered as key-only or omitted.
|
|
77
89
|
* **Simplified Removal (`.remove()`)**: To remove a component from its parent, simply call `self.remove()` without arguments.
|
|
78
90
|
* **Rapid Content Replacement (`.text`)**: Use the `.text` property on any tag to quickly replace its inner text content without needing to manually clear its children first.
|
|
79
91
|
* **Recursive Statics & JS**: Components created dynamically (via lambdas) now have their `statics` (CSS) and `call_js` commands correctly collected and sent to the client.
|
|
80
92
|
* **Scoped Styles (`styles`)**: Define a `styles` class attribute on any component to get automatically scoped CSS. The framework prefixes every rule with `.htag-ClassName`, handles `@media`, `@keyframes`, pseudo-selectors, and multi-selectors.
|
|
81
93
|
* **CSS Class Helpers**: `add_class()`, `remove_class()`, `toggle_class()`, and `has_class()` for convenient class manipulation without manual string handling.
|
|
82
|
-
* **Simple Events & HashChange**: Support for passing primitive values or custom objects from JS (via `tag["onclick"] = func`
|
|
94
|
+
* **Simple Events & HashChange**: Support for passing primitive values or custom objects from JS (via `tag["onclick"] = func`). Includes built-in support for `onhashchange`.
|
|
83
95
|
* **Fragments (`Tag()`)**: Create virtual components that group children without adding a wrapper tag to the DOM.
|
|
84
96
|
* **Advanced Tag Search (`.find_tag()`)**: Effortlessly locate any component in the tree by its internal htag ID or its manually assigned HTML `id`.
|
|
85
97
|
* **Custom ID Resilience**: Manual HTML `id` attributes are now supported without breaking htag's reactive partial updates, thanks to an automatic `data-htag-id` fallback mechanism.
|
|
86
98
|
* **Automatic Attribute Assignment**: Non-prefixed keyword arguments passed during component instantiation are automatically assigned as instance attributes, simplifying data passing to custom components.
|
|
99
|
+
* **Unified Form Handling**: When a `Tag.form` is submitted, all input fields are automatically collected into a dictionary and passed as `event.value`. You can conveniently access fields using square brackets on the event object (e.g., `e["fieldname"]`).
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
## History
|
|
103
|
+
|
|
104
|
+
At the beginning, there was [guy](https://github.com/manatlan/guy), which was/is the same concept as [python-eel](https://github.com/ChrisKnott/Eel), but more advanced.
|
|
105
|
+
One day, I've discovered [remi](https://github.com/rawpython/remi), and asked my self, if it could be done in a *guy way*. The POC was very good, so I released
|
|
106
|
+
a version of it, named [gtag](https://github.com/manatlan/gtag). It worked well despite some drawbacks, but was too difficult to maintain. So I decided to rewrite all
|
|
107
|
+
from scratch, while staying away from *guy* (to separate, *rendering* and *runners*)... and [htag](https://github.com/manatlan/htag/tree/v1-legacy) was born. The codebase is very short, concepts are better implemented, and it's very easy to maintain. And now (2026) [htag v2](https://github.com/manatlan/htag) is here (a full rewrite of v1 with antigravity) !
|
|
108
|
+
|
|
@@ -176,7 +176,7 @@ version = 1.0
|
|
|
176
176
|
source.dir = .
|
|
177
177
|
source.include_exts = py,png,jpg,jpeg,svg,js,css,html
|
|
178
178
|
|
|
179
|
-
requirements = android,htag,starlette,uvicorn,websockets,anyio,typing_extensions,click,httpx,h11
|
|
179
|
+
requirements = android,htag>=2.0.0,starlette,uvicorn,websockets,anyio,typing_extensions,click,httpx,h11
|
|
180
180
|
orientation = {orientation}
|
|
181
181
|
fullscreen = {fullscreen}
|
|
182
182
|
android.archs = {android_archs}
|
|
@@ -143,6 +143,7 @@ function init_ws() {
|
|
|
143
143
|
|
|
144
144
|
function handle_payload(data) {
|
|
145
145
|
if(data.action == "update") {
|
|
146
|
+
console.log("htag: processing payload updates:", Object.keys(data.updates || {}));
|
|
146
147
|
// Apply partial DOM updates received from the server
|
|
147
148
|
for(var id in data.updates) {
|
|
148
149
|
var el = document.getElementById(id) || document.querySelector('[data-htag-id="' + id + '"]');
|
|
@@ -153,21 +154,46 @@ function handle_payload(data) {
|
|
|
153
154
|
if(_error_overlay && _error_overlay.parentNode !== document.body) {
|
|
154
155
|
document.body.appendChild(_error_overlay);
|
|
155
156
|
}
|
|
156
|
-
//
|
|
157
|
-
if(data.js) {
|
|
158
|
-
for(var i=0; i<data.js.length; i++) eval(data.js[i]);
|
|
159
|
-
}
|
|
160
|
-
// Inject new css/js statics if they haven't been loaded yet
|
|
157
|
+
// 1. Inject new css/js statics if they haven't been loaded yet (BEFORE JS calls)
|
|
161
158
|
if(data.statics) {
|
|
162
159
|
data.statics.forEach(s => {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
160
|
+
try {
|
|
161
|
+
var div = document.createElement('div');
|
|
162
|
+
div.innerHTML = s.trim();
|
|
163
|
+
var node = div.firstChild;
|
|
164
|
+
if (!node) return;
|
|
165
|
+
|
|
166
|
+
if (node.tagName === "STYLE" || node.tagName === "LINK") {
|
|
167
|
+
document.head.appendChild(node);
|
|
168
|
+
} else if (node.tagName === "SCRIPT") {
|
|
169
|
+
var script = document.createElement('script');
|
|
170
|
+
script.async = false; // Force sequential execution for multiple dynamic scripts
|
|
171
|
+
for (var i = 0; i < node.attributes.length; i++) {
|
|
172
|
+
var attr = node.attributes[i];
|
|
173
|
+
script.setAttribute(attr.name, attr.value);
|
|
174
|
+
}
|
|
175
|
+
if (node.textContent) script.textContent = node.textContent;
|
|
176
|
+
document.head.appendChild(script);
|
|
177
|
+
}
|
|
178
|
+
} catch(e) {
|
|
179
|
+
console.error("htag: static injection error", s, e);
|
|
168
180
|
}
|
|
169
181
|
});
|
|
170
182
|
}
|
|
183
|
+
|
|
184
|
+
// 2. Execute any JavaScript calls emitted by the Python tags
|
|
185
|
+
if(data.js) {
|
|
186
|
+
for(var i=0; i<data.js.length; i++) {
|
|
187
|
+
try {
|
|
188
|
+
eval(data.js[i]);
|
|
189
|
+
} catch(e) {
|
|
190
|
+
console.error("htag: eval error for", data.js[i], e);
|
|
191
|
+
if(_error_overlay && typeof _error_overlay.show === 'function') {
|
|
192
|
+
_error_overlay.show("JS Eval Error", e.message + "\\nSource: " + data.js[i]);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
171
197
|
// Resolve promise if a result is returned for a callback
|
|
172
198
|
if(data.callback_id && window._htag_callbacks[data.callback_id]) {
|
|
173
199
|
window._htag_callbacks[data.callback_id](data.result);
|
|
@@ -262,11 +288,19 @@ function htag_event(id, event_name, event) {
|
|
|
262
288
|
|
|
263
289
|
if (event instanceof Event) {
|
|
264
290
|
// Standard DOM Event
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
291
|
+
var target = event.target;
|
|
292
|
+
if (target && target.tagName) { // Ensure target is a DOM element
|
|
293
|
+
// Check if the event source is a form or inside a form
|
|
294
|
+
var form = (target.tagName === 'FORM') ? target : target.closest('form');
|
|
295
|
+
if (form && event_name === 'submit') {
|
|
296
|
+
// Collect all form data into value attribute (standard htag v2 pattern)
|
|
297
|
+
var formData = new FormData(form);
|
|
298
|
+
data.value = {};
|
|
299
|
+
formData.forEach((v, k) => { data.value[k] = v; });
|
|
300
|
+
} else if (target.type === 'checkbox') {
|
|
301
|
+
data.value = target.checked;
|
|
268
302
|
} else {
|
|
269
|
-
data.value =
|
|
303
|
+
data.value = target.value;
|
|
270
304
|
}
|
|
271
305
|
}
|
|
272
306
|
data.key = event.key;
|
|
@@ -285,7 +319,6 @@ function htag_event(id, event_name, event) {
|
|
|
285
319
|
}
|
|
286
320
|
|
|
287
321
|
var payload = {id: id, event: event_name, data: data};
|
|
288
|
-
|
|
289
322
|
window.htag_transport(payload);
|
|
290
323
|
|
|
291
324
|
return new Promise(resolve => {
|
|
@@ -562,15 +562,27 @@ class GTag: # aka "Generic Tag"
|
|
|
562
562
|
"parent",
|
|
563
563
|
"tag",
|
|
564
564
|
"id",
|
|
565
|
+
"_reload",
|
|
566
|
+
"_browser_cleanup",
|
|
565
567
|
):
|
|
566
568
|
super().__setattr__(name, value)
|
|
567
569
|
elif name.startswith("_on") and (callable(value) or isinstance(value, str)):
|
|
568
570
|
# Event (e.g., self._onclick = my_callback or self._onclick = "alert(1)")
|
|
571
|
+
logger.warning(
|
|
572
|
+
"DEPRECATION: Setting events via underscore prefix ('%s') is deprecated. Use 'self[\"%s\"] = ...' instead.",
|
|
573
|
+
name,
|
|
574
|
+
name[1:],
|
|
575
|
+
)
|
|
569
576
|
with self.__lock:
|
|
570
577
|
self.__events[name[3:]] = value
|
|
571
578
|
self.__dirty = True
|
|
572
579
|
elif name.startswith("_"):
|
|
573
580
|
# HTML attribute (e.g., self._class = "foo")
|
|
581
|
+
logger.warning(
|
|
582
|
+
"DEPRECATION: Setting HTML attributes via underscore prefix ('%s') is deprecated. Use 'self[\"%s\"] = ...' instead.",
|
|
583
|
+
name,
|
|
584
|
+
name[1:].replace("_", "-"),
|
|
585
|
+
)
|
|
574
586
|
attr_name = name[1:].replace("_", "-")
|
|
575
587
|
with self.__lock:
|
|
576
588
|
self.__attrs[attr_name] = value
|
|
@@ -580,15 +592,33 @@ class GTag: # aka "Generic Tag"
|
|
|
580
592
|
super().__setattr__(name, value)
|
|
581
593
|
|
|
582
594
|
def __getattr__(self, name: str) -> Any:
|
|
595
|
+
if name in ("_reload", "_browser_cleanup"):
|
|
596
|
+
return super().__getattribute__(name)
|
|
583
597
|
if name.startswith("_") and "__" not in name:
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
598
|
+
if name.startswith("_on"):
|
|
599
|
+
event_name = name[3:]
|
|
600
|
+
events = super().__getattribute__("_GTag__events")
|
|
601
|
+
if event_name in events:
|
|
602
|
+
logger.warning(
|
|
603
|
+
"DEPRECATION: Accessing events via underscore prefix ('%s') is deprecated. Use 'self[\"%s\"]' instead.",
|
|
604
|
+
name,
|
|
605
|
+
name[1:],
|
|
606
|
+
)
|
|
607
|
+
return events[event_name]
|
|
608
|
+
else:
|
|
609
|
+
try:
|
|
610
|
+
# Use super().__getattribute__ to avoid recursion loop with __getattr__
|
|
611
|
+
attrs = super().__getattribute__("_GTag__attrs")
|
|
612
|
+
attr_name = name[1:].replace("_", "-")
|
|
613
|
+
if attr_name in attrs:
|
|
614
|
+
logger.warning(
|
|
615
|
+
"DEPRECATION: Accessing HTML attributes via underscore prefix ('%s') is deprecated. Use 'self[\"%s\"]' instead.",
|
|
616
|
+
name,
|
|
617
|
+
attr_name,
|
|
618
|
+
)
|
|
619
|
+
return attrs[attr_name]
|
|
620
|
+
except AttributeError:
|
|
621
|
+
pass
|
|
592
622
|
return super().__getattribute__(name)
|
|
593
623
|
|
|
594
624
|
def __getitem__(self, name: str) -> Any:
|
|
@@ -34,13 +34,18 @@ class Event:
|
|
|
34
34
|
self.target = target
|
|
35
35
|
self.id: str = msg.get("id", "")
|
|
36
36
|
self.name: str = msg.get("event", "")
|
|
37
|
+
# The primary data payload (htag v2 pattern)
|
|
38
|
+
self.value = msg.get("data")
|
|
37
39
|
# Flat access to msg['data'] (e.g., e.value, e.x, etc.)
|
|
38
|
-
data =
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
40
|
+
data = self.value if isinstance(self.value, dict) else {}
|
|
41
|
+
for k, v in data.items():
|
|
42
|
+
setattr(self, k, v)
|
|
43
|
+
|
|
44
|
+
def __getitem__(self, name: str) -> Any:
|
|
45
|
+
val = getattr(self, "value", None)
|
|
46
|
+
if isinstance(val, dict) and name in val:
|
|
47
|
+
return val[name]
|
|
48
|
+
return getattr(self, name, None)
|
|
44
49
|
|
|
45
50
|
def __getattr__(self, name: str) -> Any:
|
|
46
51
|
return None
|
|
@@ -270,6 +275,7 @@ class AppRunner(BaseApp):
|
|
|
270
275
|
self._walk_tree(tag, visitor)
|
|
271
276
|
|
|
272
277
|
async def handle_event(self, msg: dict[str, Any], ws: WebSocket | None) -> None:
|
|
278
|
+
print(f"SERVERSIDE: handle_event {msg}")
|
|
273
279
|
tag_id: str | None = msg.get("id")
|
|
274
280
|
event_name: str | None = msg.get("event")
|
|
275
281
|
|
|
@@ -203,7 +203,8 @@ class ChromeApp:
|
|
|
203
203
|
def on_inst(inst: App) -> None:
|
|
204
204
|
inst.exit_on_disconnect = True
|
|
205
205
|
if self._cleanup_func:
|
|
206
|
-
|
|
206
|
+
# Use object.__setattr__ to bypass GTag.__setattr__ and avoid deprecation warning
|
|
207
|
+
object.__setattr__(inst, "_browser_cleanup", self._cleanup_func)
|
|
207
208
|
|
|
208
209
|
if not inspect.isclass(self.app):
|
|
209
210
|
self.app.exit_on_disconnect = True
|
|
@@ -93,14 +93,10 @@ class WebApp:
|
|
|
93
93
|
token = current_request.set(request_or_ws)
|
|
94
94
|
try:
|
|
95
95
|
if inspect.isclass(self.tag_entity):
|
|
96
|
-
|
|
97
|
-
self.instances[sid] = self.tag_entity()
|
|
98
|
-
else:
|
|
99
|
-
# Wrap plain GTag class in an App runner
|
|
100
|
-
self.instances[sid] = AppRunner(self.tag_entity)
|
|
96
|
+
self.instances[sid] = self.tag_entity()
|
|
101
97
|
logger.info("Created new session instance for sid: %s", sid)
|
|
102
98
|
else:
|
|
103
|
-
# tag_entity is an App instance
|
|
99
|
+
# tag_entity is an App instance (shared)
|
|
104
100
|
self.instances[sid] = self.tag_entity # type: ignore
|
|
105
101
|
logger.info(
|
|
106
102
|
"Using shared instance for session sid: %s", sid
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: htag
|
|
3
|
-
Version: 2.0.
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 2.0.2
|
|
4
|
+
Summary: Python3 GUI toolkit for building 'beautiful' applications for mobile, web, and desktop from a single codebase
|
|
5
5
|
Author: manatlan
|
|
6
6
|
License: MIT
|
|
7
7
|
Project-URL: Homepage, https://github.com/manatlan/htag
|
|
@@ -37,31 +37,46 @@ Requires-Dist: websockets>=16.0
|
|
|
37
37
|
</a>
|
|
38
38
|
</p>
|
|
39
39
|
|
|
40
|
-
|
|
40
|
+
**htag** is a Python3 GUI toolkit for building "beautiful" applications for mobile, web, and desktop from a single codebase
|
|
41
41
|
|
|
42
|
-
|
|
42
|
+
Here is a full rewrite of **htag 1.0.0**, using only antigravity and prompts/intentions. It's the future of **htag**.
|
|
43
43
|
|
|
44
44
|
Currently, it works ...
|
|
45
45
|
|
|
46
|
-
- For building desktop apps (ChromeApp on linux/windows)
|
|
46
|
+
- For building desktop apps (ChromeApp on linux/windows/mac, or simple WebApp)
|
|
47
47
|
- For building web apps (WebApp as an asgi app (for starlette/fastapi/...))
|
|
48
48
|
- For building SPA HTML Page (with the PyScript runner)
|
|
49
49
|
- For building android apps ([it works](examples/app_android/README.md), **but need to improve**)
|
|
50
50
|
|
|
51
|
+
## Major differences between v1 and v2
|
|
52
|
+
|
|
53
|
+
- A lot simpler & more reactive
|
|
54
|
+
- `Tag.App` is the main app component
|
|
55
|
+
- Full starlette compliant from the ground
|
|
56
|
+
- Websocket communication, if fails : fallback to HTTP SSE
|
|
57
|
+
- No more generic Runner
|
|
58
|
+
- State object for reactivity
|
|
59
|
+
- DX: errors are a lot better handled/viewable, hot-reload available
|
|
60
|
+
- Better events
|
|
61
|
+
- less boilerplate (kiss minded)
|
|
62
|
+
- Styles can be scoped by component
|
|
63
|
+
- ...
|
|
64
|
+
|
|
51
65
|
|
|
52
66
|
## Get Started
|
|
53
67
|
|
|
54
|
-
Check the [Official Documentation](https://manatlan.github.io/htag/) for more information.
|
|
68
|
+
Check the [Official V2 Documentation](https://manatlan.github.io/htag/) for more information. [old v1 docs](https://manatlan.github.io/htag/v1/)
|
|
69
|
+
|
|
55
70
|
|
|
56
71
|
## Install
|
|
57
72
|
|
|
58
73
|
```bash
|
|
59
|
-
uv add htag
|
|
74
|
+
uv add htag -U
|
|
60
75
|
```
|
|
61
76
|
|
|
62
77
|
Or using pip:
|
|
63
78
|
```bash
|
|
64
|
-
pip install htag
|
|
79
|
+
pip install -U htag
|
|
65
80
|
```
|
|
66
81
|
|
|
67
82
|
Alternatively, you can run from source:
|
|
@@ -73,35 +88,42 @@ uv run examples/main3.py
|
|
|
73
88
|
|
|
74
89
|
### Skill
|
|
75
90
|
|
|
76
|
-
With
|
|
91
|
+
With agentic llm, you can use this [SKILL.md](.agent/skills/htag-development/SKILL.md) to create an htag application.
|
|
77
92
|
|
|
78
93
|
|
|
79
94
|
## Antigravity resumes :
|
|
80
95
|
|
|
81
96
|
htag is a Python library for building web applications using HTML, CSS, and JavaScript.
|
|
82
97
|
|
|
83
|
-
###
|
|
98
|
+
### New Features
|
|
84
99
|
* **Zero-Config Hot-Reload**: Passing `reload=True` to any runner (e.g. `ChromeApp(App).run(reload=True)`) automatically watches for Python file changes, seamlessly restarts the backend, and gracefully refreshes the frontend without losing your browser window session.
|
|
85
100
|
* **F5/Reload Robustness**: Refreshing the browser no longer kills the Python backend; the session reconstructs cleanly.
|
|
86
101
|
* **HTTP Fallback (SSE + POST)**: If WebSockets are blocked (e.g. strict proxies) or fail to connect, the client seamlessly falls back to HTTP POST for events and Server-Sent Events (SSE) for receiving UI updates.
|
|
87
102
|
* **Production Debug Mode**: Easily disable error reporting in the client by setting `debug=False` on the runner (e.g. `WebApp(App, debug=False).app`), preventing internal stacktraces from leaking to users.
|
|
88
103
|
* **Parano Mode (Payload Obfuscation)**: By initializing `WebApp(App, parano=True)`, all data exchanged between the frontend and backend is automatically obfuscated using a dynamic XOR cipher and Base64 wrapping, making network traffic unreadable to MITM proxies.
|
|
89
|
-
|
|
90
|
-
### New API Features
|
|
91
104
|
* **`.root`, `.parent`, and `.childs` properties**: Every `GTag` exposes its position in the component tree. `.root` references the main `Tag` instance, `.parent` references the direct parent component, and `.childs` is a list of its children. This allows components to easily navigate the DOM tree and trigger app-level actions.
|
|
92
105
|
* **Declarative UI with Context Managers (`with`)**: You can now build component trees visually using `with` blocks (e.g., `with Tag.div(): Tag.h1("Hello")`), removing the need for `self <= ...` boilerplate.
|
|
93
|
-
* **
|
|
94
|
-
* **Automatic Attribute Normalization**: HTML attributes are normalized to use dashes internally. Setting `self._data_id = "123"` is strictly equivalent to setting `self["data-id"] = "123"`.
|
|
106
|
+
* **Modern Attribute Access (Dictionary Style)**: In addition to `Tag.div(_class="foo")` in constructors, you should now use `self["class"] = "foo"` for dynamic property management. This is the preferred way to handle all attributes, including those with dashes (e.g., `self["data-id"] = 123`).
|
|
95
107
|
* **Reactive State Management (`State`)**: Introducing `State(value)` for automatic UI reactivity. `State` acts as a transparent **Proxy**: you can use comparison/arithmetic operators directly (`if self.count > 0: ...`), mutate nested structures (including `lists`, `dicts`, `sets`, and `tuples`), and delegate attribute assignments seamlessly.
|
|
96
108
|
* **Iterative & Attribute Reactivity**: Iterating over a `State` now yields proxies, meaning mutations like `for item in self.list: item.active = True` trigger re-renders. Attribute assignments (e.g. `self.user.name = "Bob"`) are automatically delegated to the underlying object and notified.
|
|
97
|
-
* **Reactive & Boolean Attributes**: Attributes like `_class`, `_style`, or `_disabled` now support lambdas for dynamic updates. Boolean attributes (e.g. `_disabled=True`) are correctly rendered as key-only or omitted.
|
|
109
|
+
* **Reactive & Boolean Attributes**: Attributes like `_class`, `_style`, or `_disabled` in constructors now support lambdas for dynamic updates. Boolean attributes (e.g. `_disabled=True`) are correctly rendered as key-only or omitted.
|
|
98
110
|
* **Simplified Removal (`.remove()`)**: To remove a component from its parent, simply call `self.remove()` without arguments.
|
|
99
111
|
* **Rapid Content Replacement (`.text`)**: Use the `.text` property on any tag to quickly replace its inner text content without needing to manually clear its children first.
|
|
100
112
|
* **Recursive Statics & JS**: Components created dynamically (via lambdas) now have their `statics` (CSS) and `call_js` commands correctly collected and sent to the client.
|
|
101
113
|
* **Scoped Styles (`styles`)**: Define a `styles` class attribute on any component to get automatically scoped CSS. The framework prefixes every rule with `.htag-ClassName`, handles `@media`, `@keyframes`, pseudo-selectors, and multi-selectors.
|
|
102
114
|
* **CSS Class Helpers**: `add_class()`, `remove_class()`, `toggle_class()`, and `has_class()` for convenient class manipulation without manual string handling.
|
|
103
|
-
* **Simple Events & HashChange**: Support for passing primitive values or custom objects from JS (via `tag["onclick"] = func`
|
|
115
|
+
* **Simple Events & HashChange**: Support for passing primitive values or custom objects from JS (via `tag["onclick"] = func`). Includes built-in support for `onhashchange`.
|
|
104
116
|
* **Fragments (`Tag()`)**: Create virtual components that group children without adding a wrapper tag to the DOM.
|
|
105
117
|
* **Advanced Tag Search (`.find_tag()`)**: Effortlessly locate any component in the tree by its internal htag ID or its manually assigned HTML `id`.
|
|
106
118
|
* **Custom ID Resilience**: Manual HTML `id` attributes are now supported without breaking htag's reactive partial updates, thanks to an automatic `data-htag-id` fallback mechanism.
|
|
107
119
|
* **Automatic Attribute Assignment**: Non-prefixed keyword arguments passed during component instantiation are automatically assigned as instance attributes, simplifying data passing to custom components.
|
|
120
|
+
* **Unified Form Handling**: When a `Tag.form` is submitted, all input fields are automatically collected into a dictionary and passed as `event.value`. You can conveniently access fields using square brackets on the event object (e.g., `e["fieldname"]`).
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
## History
|
|
124
|
+
|
|
125
|
+
At the beginning, there was [guy](https://github.com/manatlan/guy), which was/is the same concept as [python-eel](https://github.com/ChrisKnott/Eel), but more advanced.
|
|
126
|
+
One day, I've discovered [remi](https://github.com/rawpython/remi), and asked my self, if it could be done in a *guy way*. The POC was very good, so I released
|
|
127
|
+
a version of it, named [gtag](https://github.com/manatlan/gtag). It worked well despite some drawbacks, but was too difficult to maintain. So I decided to rewrite all
|
|
128
|
+
from scratch, while staying away from *guy* (to separate, *rendering* and *runners*)... and [htag](https://github.com/manatlan/htag/tree/v1-legacy) was born. The codebase is very short, concepts are better implemented, and it's very easy to maintain. And now (2026) [htag v2](https://github.com/manatlan/htag) is here (a full rewrite of v1 with antigravity) !
|
|
129
|
+
|
|
@@ -9,8 +9,8 @@ include-package-data = true
|
|
|
9
9
|
|
|
10
10
|
[project]
|
|
11
11
|
name = "htag"
|
|
12
|
-
version = "2.0.
|
|
13
|
-
description = "
|
|
12
|
+
version = "2.0.2"
|
|
13
|
+
description = "Python3 GUI toolkit for building 'beautiful' applications for mobile, web, and desktop from a single codebase"
|
|
14
14
|
readme = "README.md"
|
|
15
15
|
requires-python = ">=3.10"
|
|
16
16
|
license = {text = "MIT"}
|
|
@@ -46,6 +46,7 @@ dev = [
|
|
|
46
46
|
"pytest>=9.0.2",
|
|
47
47
|
"pytest-asyncio>=1.3.0",
|
|
48
48
|
"pytest-cov>=7.0.0",
|
|
49
|
+
"pytest-playwright>=0.7.2",
|
|
49
50
|
"ruff>=0.15.4",
|
|
50
51
|
]
|
|
51
52
|
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import logging
|
|
3
|
+
from htag import Tag
|
|
4
|
+
|
|
5
|
+
def test_instantiation_no_warning(caplog):
|
|
6
|
+
"""
|
|
7
|
+
Ensures that underscore-prefixed attributes during instantiation
|
|
8
|
+
do NOT trigger a deprecation warning.
|
|
9
|
+
"""
|
|
10
|
+
with caplog.at_level(logging.WARNING, logger="htag"):
|
|
11
|
+
t = Tag.div(_class="myclass", _onclick="alert(1)")
|
|
12
|
+
assert len(caplog.records) == 0
|
|
13
|
+
assert t["class"] == "myclass"
|
|
14
|
+
assert t["onclick"] == "alert(1)"
|
|
15
|
+
|
|
16
|
+
def test_getattr_warning(caplog):
|
|
17
|
+
"""
|
|
18
|
+
Ensures that accessing underscore-prefixed attributes triggers a warning.
|
|
19
|
+
"""
|
|
20
|
+
t = Tag.div(_class="myclass")
|
|
21
|
+
caplog.clear()
|
|
22
|
+
with caplog.at_level(logging.WARNING, logger="htag"):
|
|
23
|
+
c = t._class
|
|
24
|
+
assert c == "myclass"
|
|
25
|
+
assert len(caplog.records) == 1
|
|
26
|
+
assert "DEPRECATION" in caplog.text
|
|
27
|
+
assert "Accessing HTML attributes via underscore prefix ('_class')" in caplog.text
|
|
28
|
+
|
|
29
|
+
def test_setattr_warning(caplog):
|
|
30
|
+
"""
|
|
31
|
+
Ensures that setting underscore-prefixed attributes triggers a warning.
|
|
32
|
+
"""
|
|
33
|
+
t = Tag.div()
|
|
34
|
+
caplog.clear()
|
|
35
|
+
with caplog.at_level(logging.WARNING, logger="htag"):
|
|
36
|
+
t._class = "newclass"
|
|
37
|
+
assert t["class"] == "newclass"
|
|
38
|
+
assert len(caplog.records) == 1
|
|
39
|
+
assert "DEPRECATION" in caplog.text
|
|
40
|
+
assert "Setting HTML attributes via underscore prefix ('_class')" in caplog.text
|
|
41
|
+
|
|
42
|
+
def test_get_event_warning(caplog):
|
|
43
|
+
"""
|
|
44
|
+
Ensures that accessing underscore-prefixed events triggers a warning.
|
|
45
|
+
"""
|
|
46
|
+
t = Tag.div(_onclick="alert(1)")
|
|
47
|
+
caplog.clear()
|
|
48
|
+
with caplog.at_level(logging.WARNING, logger="htag"):
|
|
49
|
+
oc = t._onclick
|
|
50
|
+
assert oc == "alert(1)"
|
|
51
|
+
assert len(caplog.records) == 1
|
|
52
|
+
assert "DEPRECATION" in caplog.text
|
|
53
|
+
assert "Accessing events via underscore prefix ('_onclick')" in caplog.text
|
|
54
|
+
|
|
55
|
+
def test_set_event_warning(caplog):
|
|
56
|
+
"""
|
|
57
|
+
Ensures that setting underscore-prefixed events triggers a warning.
|
|
58
|
+
"""
|
|
59
|
+
t = Tag.div()
|
|
60
|
+
caplog.clear()
|
|
61
|
+
with caplog.at_level(logging.WARNING, logger="htag"):
|
|
62
|
+
t._onclick = "alert(2)"
|
|
63
|
+
assert t["onclick"] == "alert(2)"
|
|
64
|
+
assert len(caplog.records) == 1
|
|
65
|
+
assert "DEPRECATION" in caplog.text
|
|
66
|
+
assert "Setting events via underscore prefix ('_onclick')" in caplog.text
|
|
67
|
+
|
|
68
|
+
def test_getattr_no_warning_on_missing(caplog):
|
|
69
|
+
"""
|
|
70
|
+
Accessing a non-existent underscore attribute should still raise AttributeError or return what super does,
|
|
71
|
+
but shouldn't warn if it doesn't exist in attrs/events.
|
|
72
|
+
"""
|
|
73
|
+
t = Tag.div()
|
|
74
|
+
caplog.clear()
|
|
75
|
+
with caplog.at_level(logging.WARNING, logger="htag"):
|
|
76
|
+
with pytest.raises(AttributeError):
|
|
77
|
+
_ = t._non_existent
|
|
78
|
+
assert len(caplog.records) == 0
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import json
|
|
3
|
+
from unittest.mock import MagicMock, AsyncMock
|
|
4
|
+
from htag.server import Event, AppRunner as App
|
|
5
|
+
from htag import Tag
|
|
6
|
+
|
|
7
|
+
@pytest.mark.asyncio
|
|
8
|
+
async def test_event_simple_value():
|
|
9
|
+
target = MagicMock()
|
|
10
|
+
msg = {
|
|
11
|
+
"id": "123",
|
|
12
|
+
"event": "custom",
|
|
13
|
+
"data": "hello"
|
|
14
|
+
}
|
|
15
|
+
e = Event(target, msg)
|
|
16
|
+
assert e.value == "hello"
|
|
17
|
+
|
|
18
|
+
@pytest.mark.asyncio
|
|
19
|
+
async def test_event_hashchange_data():
|
|
20
|
+
target = MagicMock()
|
|
21
|
+
msg = {
|
|
22
|
+
"id": "123",
|
|
23
|
+
"event": "hashchange",
|
|
24
|
+
"data": {
|
|
25
|
+
"newURL": "http://localhost/#new",
|
|
26
|
+
"oldURL": "http://localhost/#old",
|
|
27
|
+
"callback_id": "123"
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
e = Event(target, msg)
|
|
31
|
+
assert e.newURL == "http://localhost/#new"
|
|
32
|
+
assert e.oldURL == "http://localhost/#old"
|
|
33
|
+
|
|
34
|
+
@pytest.mark.asyncio
|
|
35
|
+
async def test_app_handle_simple_event():
|
|
36
|
+
app = App()
|
|
37
|
+
shared = {"val": None}
|
|
38
|
+
|
|
39
|
+
def my_cb(e):
|
|
40
|
+
shared["val"] = e.value
|
|
41
|
+
|
|
42
|
+
# We simulate a tag that has a custom event handler
|
|
43
|
+
class MyTag(Tag.div):
|
|
44
|
+
def init(self):
|
|
45
|
+
self._oncustom = my_cb
|
|
46
|
+
|
|
47
|
+
tag = MyTag()
|
|
48
|
+
app += tag
|
|
49
|
+
|
|
50
|
+
ws = AsyncMock()
|
|
51
|
+
msg = {
|
|
52
|
+
"id": tag.id,
|
|
53
|
+
"event": "custom",
|
|
54
|
+
"data": "simple_value"
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
await app.handle_event(msg, ws)
|
|
58
|
+
assert shared["val"] == "simple_value"
|
|
59
|
+
|
|
60
|
+
@pytest.mark.asyncio
|
|
61
|
+
async def test_app_handle_hashchange_event():
|
|
62
|
+
app = App()
|
|
63
|
+
shared = {"new": None, "old": None}
|
|
64
|
+
|
|
65
|
+
def on_hash(e):
|
|
66
|
+
shared["new"] = e.newURL
|
|
67
|
+
shared["old"] = e.oldURL
|
|
68
|
+
|
|
69
|
+
app._onhashchange = on_hash
|
|
70
|
+
|
|
71
|
+
ws = AsyncMock()
|
|
72
|
+
msg = {
|
|
73
|
+
"id": app.id,
|
|
74
|
+
"event": "hashchange",
|
|
75
|
+
"data": {
|
|
76
|
+
"newURL": "new_url",
|
|
77
|
+
"oldURL": "old_url"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
await app.handle_event(msg, ws)
|
|
82
|
+
assert shared["new"] == "new_url"
|
|
83
|
+
assert shared["old"] == "old_url"
|
|
84
|
+
|
|
85
|
+
@pytest.mark.asyncio
|
|
86
|
+
async def test_event_dict_value():
|
|
87
|
+
target = MagicMock()
|
|
88
|
+
msg = {
|
|
89
|
+
"id": "123",
|
|
90
|
+
"event": "submit",
|
|
91
|
+
"data": {
|
|
92
|
+
"name": "manatlan",
|
|
93
|
+
"age": 42
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
e = Event(target, msg)
|
|
97
|
+
# Standard htag v2: data is in .value
|
|
98
|
+
assert e.value == {"name": "manatlan", "age": 42}
|
|
99
|
+
# Backward compatibility / Convenience: subscriptable access
|
|
100
|
+
assert e["name"] == "manatlan"
|
|
101
|
+
assert e["age"] == 42
|
|
102
|
+
assert e["missing"] is None
|
|
103
|
+
|
|
104
|
+
@pytest.mark.asyncio
|
|
105
|
+
async def test_event_getitem_fallback_to_attr():
|
|
106
|
+
target = MagicMock()
|
|
107
|
+
msg = {
|
|
108
|
+
"id": "123",
|
|
109
|
+
"event": "click",
|
|
110
|
+
"data": {
|
|
111
|
+
"pageX": 100,
|
|
112
|
+
"pageY": 200
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
e = Event(target, msg)
|
|
116
|
+
# Should find in attributes (Event sets attrs from msg['data'])
|
|
117
|
+
assert e["pageX"] == 100
|
|
118
|
+
assert e.pageX == 100
|
|
119
|
+
# Should work via __getitem__ too
|
|
120
|
+
assert e["id"] == "123"
|
|
121
|
+
|
|
122
|
+
@pytest.mark.asyncio
|
|
123
|
+
async def test_app_handle_form_event():
|
|
124
|
+
app = App()
|
|
125
|
+
shared = {"data": None}
|
|
126
|
+
|
|
127
|
+
def on_submit(e):
|
|
128
|
+
shared["data"] = e.value
|
|
129
|
+
|
|
130
|
+
class MyForm(Tag.form):
|
|
131
|
+
def init(self):
|
|
132
|
+
self._onsubmit = on_submit
|
|
133
|
+
|
|
134
|
+
tag = MyForm()
|
|
135
|
+
app += tag
|
|
136
|
+
|
|
137
|
+
ws = AsyncMock()
|
|
138
|
+
msg = {
|
|
139
|
+
"id": tag.id,
|
|
140
|
+
"event": "submit",
|
|
141
|
+
"data": {
|
|
142
|
+
"value": {"field1": "val1", "field2": "val2"}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
await app.handle_event(msg, ws)
|
|
147
|
+
assert shared["data"] == {"field1": "val1", "field2": "val2"}
|
|
148
|
+
|
|
149
|
+
@pytest.mark.asyncio
|
|
150
|
+
async def test_event_priority_collision():
|
|
151
|
+
target = MagicMock()
|
|
152
|
+
msg = {
|
|
153
|
+
"id": "real_id",
|
|
154
|
+
"event": "submit",
|
|
155
|
+
"data": {
|
|
156
|
+
"id": "form_id", # Collision with event.id
|
|
157
|
+
"name": "bob"
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
e = Event(target, msg)
|
|
161
|
+
# The form data 'id' should win in subscript access
|
|
162
|
+
assert e["id"] == "form_id"
|
|
163
|
+
# But event.id (attribute) was overwritten by the flat dict expansion in __init__
|
|
164
|
+
assert e.id == "form_id"
|
|
165
|
+
|
|
166
|
+
@pytest.mark.asyncio
|
|
167
|
+
async def test_event_flat_value_subscript():
|
|
168
|
+
target = MagicMock()
|
|
169
|
+
msg = {
|
|
170
|
+
"id": "123",
|
|
171
|
+
"event": "click",
|
|
172
|
+
"data": "just_a_string"
|
|
173
|
+
}
|
|
174
|
+
e = Event(target, msg)
|
|
175
|
+
assert e.value == "just_a_string"
|
|
176
|
+
# Subscript should fall back to attributes even if value isn't a dict
|
|
177
|
+
assert e["id"] == "123"
|
|
178
|
+
assert e["missing"] is None
|
|
179
|
+
|
|
180
|
+
@pytest.mark.asyncio
|
|
181
|
+
async def test_event_empty_data():
|
|
182
|
+
target = MagicMock()
|
|
183
|
+
msg = {
|
|
184
|
+
"id": "123",
|
|
185
|
+
"event": "click"
|
|
186
|
+
# data is missing
|
|
187
|
+
}
|
|
188
|
+
e = Event(target, msg)
|
|
189
|
+
assert e.value is None
|
|
190
|
+
assert e["id"] == "123"
|
|
@@ -1,83 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
import json
|
|
3
|
-
from unittest.mock import MagicMock, AsyncMock
|
|
4
|
-
from htag.server import Event, AppRunner as App
|
|
5
|
-
from htag import Tag
|
|
6
|
-
|
|
7
|
-
@pytest.mark.asyncio
|
|
8
|
-
async def test_event_simple_value():
|
|
9
|
-
target = MagicMock()
|
|
10
|
-
msg = {
|
|
11
|
-
"id": "123",
|
|
12
|
-
"event": "custom",
|
|
13
|
-
"data": "hello"
|
|
14
|
-
}
|
|
15
|
-
e = Event(target, msg)
|
|
16
|
-
assert e.value == "hello"
|
|
17
|
-
|
|
18
|
-
@pytest.mark.asyncio
|
|
19
|
-
async def test_event_hashchange_data():
|
|
20
|
-
target = MagicMock()
|
|
21
|
-
msg = {
|
|
22
|
-
"id": "123",
|
|
23
|
-
"event": "hashchange",
|
|
24
|
-
"data": {
|
|
25
|
-
"newURL": "http://localhost/#new",
|
|
26
|
-
"oldURL": "http://localhost/#old",
|
|
27
|
-
"callback_id": "123"
|
|
28
|
-
}
|
|
29
|
-
}
|
|
30
|
-
e = Event(target, msg)
|
|
31
|
-
assert e.newURL == "http://localhost/#new"
|
|
32
|
-
assert e.oldURL == "http://localhost/#old"
|
|
33
|
-
|
|
34
|
-
@pytest.mark.asyncio
|
|
35
|
-
async def test_app_handle_simple_event():
|
|
36
|
-
app = App()
|
|
37
|
-
shared = {"val": None}
|
|
38
|
-
|
|
39
|
-
def my_cb(e):
|
|
40
|
-
shared["val"] = e.value
|
|
41
|
-
|
|
42
|
-
# We simulate a tag that has a custom event handler
|
|
43
|
-
class MyTag(Tag.div):
|
|
44
|
-
def init(self):
|
|
45
|
-
self._oncustom = my_cb
|
|
46
|
-
|
|
47
|
-
tag = MyTag()
|
|
48
|
-
app += tag
|
|
49
|
-
|
|
50
|
-
ws = AsyncMock()
|
|
51
|
-
msg = {
|
|
52
|
-
"id": tag.id,
|
|
53
|
-
"event": "custom",
|
|
54
|
-
"data": "simple_value"
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
await app.handle_event(msg, ws)
|
|
58
|
-
assert shared["val"] == "simple_value"
|
|
59
|
-
|
|
60
|
-
@pytest.mark.asyncio
|
|
61
|
-
async def test_app_handle_hashchange_event():
|
|
62
|
-
app = App()
|
|
63
|
-
shared = {"new": None, "old": None}
|
|
64
|
-
|
|
65
|
-
def on_hash(e):
|
|
66
|
-
shared["new"] = e.newURL
|
|
67
|
-
shared["old"] = e.oldURL
|
|
68
|
-
|
|
69
|
-
app._onhashchange = on_hash
|
|
70
|
-
|
|
71
|
-
ws = AsyncMock()
|
|
72
|
-
msg = {
|
|
73
|
-
"id": app.id,
|
|
74
|
-
"event": "hashchange",
|
|
75
|
-
"data": {
|
|
76
|
-
"newURL": "new_url",
|
|
77
|
-
"oldURL": "old_url"
|
|
78
|
-
}
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
await app.handle_event(msg, ws)
|
|
82
|
-
assert shared["new"] == "new_url"
|
|
83
|
-
assert shared["old"] == "old_url"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|