htag 2.0.3__tar.gz → 2.0.5__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.3 → htag-2.0.5}/PKG-INFO +2 -2
- {htag-2.0.3 → htag-2.0.5}/README.md +1 -1
- {htag-2.0.3 → htag-2.0.5}/htag/client_js.py +21 -19
- {htag-2.0.3 → htag-2.0.5}/htag/core.py +16 -65
- {htag-2.0.3 → htag-2.0.5}/htag/runner.py +15 -7
- {htag-2.0.3 → htag-2.0.5}/htag.egg-info/PKG-INFO +2 -2
- {htag-2.0.3 → htag-2.0.5}/htag.egg-info/SOURCES.txt +1 -0
- {htag-2.0.3 → htag-2.0.5}/pyproject.toml +2 -1
- {htag-2.0.3 → htag-2.0.5}/tests/test_core.py +175 -25
- htag-2.0.5/tests/test_deprecation_warnings.py +38 -0
- htag-2.0.5/tests/test_interacting_robustness.py +65 -0
- {htag-2.0.3 → htag-2.0.5}/tests/test_parano.py +19 -0
- {htag-2.0.3 → htag-2.0.5}/tests/test_reactivity_edge_cases.py +2 -2
- {htag-2.0.3 → htag-2.0.5}/tests/test_server.py +18 -1
- {htag-2.0.3 → htag-2.0.5}/tests/test_simple_events.py +3 -3
- htag-2.0.3/tests/test_deprecation_warnings.py +0 -74
- {htag-2.0.3 → htag-2.0.5}/htag/__init__.py +0 -0
- {htag-2.0.3 → htag-2.0.5}/htag/cli.py +0 -0
- {htag-2.0.3 → htag-2.0.5}/htag/context.py +0 -0
- {htag-2.0.3 → htag-2.0.5}/htag/css.py +0 -0
- {htag-2.0.3 → htag-2.0.5}/htag/exceptions.py +0 -0
- {htag-2.0.3 → htag-2.0.5}/htag/logo.py +0 -0
- {htag-2.0.3 → htag-2.0.5}/htag/runners/__init__.py +0 -0
- {htag-2.0.3 → htag-2.0.5}/htag/runners/chromeapp.py +0 -0
- {htag-2.0.3 → htag-2.0.5}/htag/runners/pyscript.py +0 -0
- {htag-2.0.3 → htag-2.0.5}/htag/server.py +0 -0
- {htag-2.0.3 → htag-2.0.5}/htag/tag.py +0 -0
- {htag-2.0.3 → htag-2.0.5}/htag/utils.py +0 -0
- {htag-2.0.3 → htag-2.0.5}/htag/web.py +0 -0
- {htag-2.0.3 → htag-2.0.5}/htag.egg-info/dependency_links.txt +0 -0
- {htag-2.0.3 → htag-2.0.5}/htag.egg-info/entry_points.txt +0 -0
- {htag-2.0.3 → htag-2.0.5}/htag.egg-info/requires.txt +0 -0
- {htag-2.0.3 → htag-2.0.5}/htag.egg-info/top_level.txt +0 -0
- {htag-2.0.3 → htag-2.0.5}/setup.cfg +0 -0
- {htag-2.0.3 → htag-2.0.5}/tests/test_cmd.py +0 -0
- {htag-2.0.3 → htag-2.0.5}/tests/test_fallback.py +0 -0
- {htag-2.0.3 → htag-2.0.5}/tests/test_lifecycle.py +0 -0
- {htag-2.0.3 → htag-2.0.5}/tests/test_memory.py +0 -0
- {htag-2.0.3 → htag-2.0.5}/tests/test_runner_pyscript.py +0 -0
- {htag-2.0.3 → htag-2.0.5}/tests/test_runners_reload.py +0 -0
- {htag-2.0.3 → htag-2.0.5}/tests/test_state_advanced.py +0 -0
- {htag-2.0.3 → htag-2.0.5}/tests/test_state_features.py +0 -0
- {htag-2.0.3 → htag-2.0.5}/tests/test_state_proxy.py +0 -0
- {htag-2.0.3 → htag-2.0.5}/tests/test_state_reactivity.py +0 -0
- {htag-2.0.3 → htag-2.0.5}/tests/test_tag_names.py +0 -0
- {htag-2.0.3 → htag-2.0.5}/tests/test_web_sessions.py +0 -0
- {htag-2.0.3 → htag-2.0.5}/tests/test_webapp_run.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: htag
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.5
|
|
4
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
|
|
@@ -103,7 +103,7 @@ htag is a Python library for building web applications using HTML, CSS, and Java
|
|
|
103
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.
|
|
104
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.
|
|
105
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.
|
|
106
|
-
* **Modern Attribute Access (Dictionary Style)**: In addition to `Tag.div(_class="foo")` in constructors, you
|
|
106
|
+
* **Modern Attribute Access (Dictionary Style)**: In addition to `Tag.div(_class="foo")` in constructors, you must now use `self["class"] = "foo"` for dynamic property management after instantiation. This is the only way to handle all attributes, including those with dashes (e.g., `self["data-id"] = 123`).
|
|
107
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.
|
|
108
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.
|
|
109
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.
|
|
@@ -82,7 +82,7 @@ htag is a Python library for building web applications using HTML, CSS, and Java
|
|
|
82
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.
|
|
83
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.
|
|
84
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.
|
|
85
|
-
* **Modern Attribute Access (Dictionary Style)**: In addition to `Tag.div(_class="foo")` in constructors, you
|
|
85
|
+
* **Modern Attribute Access (Dictionary Style)**: In addition to `Tag.div(_class="foo")` in constructors, you must now use `self["class"] = "foo"` for dynamic property management after instantiation. This is the only way to handle all attributes, including those with dashes (e.g., `self["data-id"] = 123`).
|
|
86
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.
|
|
87
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.
|
|
88
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.
|
|
@@ -5,18 +5,17 @@ var use_fallback = false;
|
|
|
5
5
|
var sse;
|
|
6
6
|
var _base_path = window.location.pathname.endsWith("/") ? window.location.pathname : window.location.pathname + "/";
|
|
7
7
|
window._htag_callbacks = {}; // Store promise resolvers
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
}
|
|
13
|
-
function _dec_interacting() {
|
|
14
|
-
_interacting--;
|
|
15
|
-
if(_interacting <= 0) {
|
|
16
|
-
_interacting = 0;
|
|
8
|
+
function _sync_interacting() {
|
|
9
|
+
if(Object.keys(window._htag_callbacks).length > 0) {
|
|
10
|
+
document.body.classList.add("interacting");
|
|
11
|
+
} else {
|
|
17
12
|
document.body.classList.remove("interacting");
|
|
18
13
|
}
|
|
19
14
|
}
|
|
15
|
+
function _dec_interacting(callback_id) {
|
|
16
|
+
if(callback_id) delete window._htag_callbacks[callback_id];
|
|
17
|
+
_sync_interacting();
|
|
18
|
+
}
|
|
20
19
|
|
|
21
20
|
// --- htag-error Web Component (Shadow DOM for style isolation) ---
|
|
22
21
|
class HtagError extends HTMLElement {
|
|
@@ -215,15 +214,15 @@ function handle_payload(data) {
|
|
|
215
214
|
}
|
|
216
215
|
// Resolve promise if a result is returned for a callback (even for errors)
|
|
217
216
|
if(data.callback_id && window._htag_callbacks[data.callback_id]) {
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
217
|
+
var resolve = window._htag_callbacks[data.callback_id];
|
|
218
|
+
_dec_interacting(data.callback_id);
|
|
219
|
+
resolve(data.result);
|
|
221
220
|
}
|
|
222
221
|
}
|
|
223
222
|
|
|
224
223
|
function fallback() {
|
|
225
|
-
|
|
226
|
-
|
|
224
|
+
window._htag_callbacks = {};
|
|
225
|
+
_sync_interacting();
|
|
227
226
|
if (use_fallback) return;
|
|
228
227
|
use_fallback = true;
|
|
229
228
|
if(ws) ws.close(); // Ensure ws is torn down
|
|
@@ -286,13 +285,13 @@ window.htag_transport = window.htag_transport || function(payload) {
|
|
|
286
285
|
}).then(response => {
|
|
287
286
|
|
|
288
287
|
if (!response.ok) {
|
|
289
|
-
_dec_interacting();
|
|
288
|
+
_dec_interacting(payload.data.callback_id);
|
|
290
289
|
if(_error_overlay && typeof _error_overlay.show === 'function') {
|
|
291
290
|
_error_overlay.show("HTTP Error", `Server returned status: ${response.status}`);
|
|
292
291
|
}
|
|
293
292
|
}
|
|
294
293
|
}).catch(err => {
|
|
295
|
-
_dec_interacting();
|
|
294
|
+
_dec_interacting(payload.data.callback_id);
|
|
296
295
|
console.error("htag event POST error:", err);
|
|
297
296
|
if(_error_overlay && typeof _error_overlay.show === 'function') {
|
|
298
297
|
_error_overlay.show("Network Error", "Could not reach server to trigger event.");
|
|
@@ -312,7 +311,8 @@ function htag_event(id, event_name, event) {
|
|
|
312
311
|
var target = event.target;
|
|
313
312
|
if (target && target.tagName) { // Ensure target is a DOM element
|
|
314
313
|
// Check if the event source is a form or inside a form
|
|
315
|
-
var form = (target.tagName === 'FORM') ? target : target.closest('form');
|
|
314
|
+
//var form = (target.tagName === 'FORM') ? target : target.closest('form');
|
|
315
|
+
var form = (target.tagName === 'FORM') ? target : null;
|
|
316
316
|
if (form && event_name === 'submit') {
|
|
317
317
|
// Collect all form data into value attribute (standard htag v2 pattern)
|
|
318
318
|
var formData = new FormData(form);
|
|
@@ -327,6 +327,8 @@ function htag_event(id, event_name, event) {
|
|
|
327
327
|
data.key = event.key;
|
|
328
328
|
data.pageX = event.pageX;
|
|
329
329
|
data.pageY = event.pageY;
|
|
330
|
+
data.button = event.button;
|
|
331
|
+
data.which = event.which;
|
|
330
332
|
|
|
331
333
|
// HashChangeEvent specifics
|
|
332
334
|
if (event.newURL) data.newURL = event.newURL;
|
|
@@ -340,11 +342,11 @@ function htag_event(id, event_name, event) {
|
|
|
340
342
|
}
|
|
341
343
|
|
|
342
344
|
var payload = {id: id, event: event_name, data: data};
|
|
343
|
-
_inc_interacting();
|
|
344
|
-
window.htag_transport(payload);
|
|
345
345
|
|
|
346
346
|
return new Promise(resolve => {
|
|
347
347
|
window._htag_callbacks[callback_id] = resolve;
|
|
348
|
+
_sync_interacting();
|
|
349
|
+
window.htag_transport(payload);
|
|
348
350
|
});
|
|
349
351
|
}
|
|
350
352
|
"""
|
|
@@ -338,6 +338,7 @@ class GTag: # aka "Generic Tag"
|
|
|
338
338
|
_alt: str
|
|
339
339
|
_title: str
|
|
340
340
|
_tabindex: int | str
|
|
341
|
+
__iter__ = None # Prevent iteration via __getitem__ fallback
|
|
341
342
|
|
|
342
343
|
# --- Common Events (Type hints for IDE autocompletion) ---
|
|
343
344
|
_onclick: Callable | str
|
|
@@ -477,8 +478,8 @@ class GTag: # aka "Generic Tag"
|
|
|
477
478
|
# We use GTag directly to create the style (GTag is already in scope)
|
|
478
479
|
style_tag = GTag("style", scoped_css)
|
|
479
480
|
existing_statics = getattr(cls, "statics", [])
|
|
480
|
-
if not isinstance(existing_statics, list):
|
|
481
|
-
existing_statics = []
|
|
481
|
+
if not isinstance(existing_statics, (list, tuple)):
|
|
482
|
+
existing_statics = [existing_statics]
|
|
482
483
|
setattr(cls, "statics", list(existing_statics) + [style_tag])
|
|
483
484
|
setattr(cls, "_scoped_static", True)
|
|
484
485
|
|
|
@@ -548,70 +549,15 @@ class GTag: # aka "Generic Tag"
|
|
|
548
549
|
def __le__(self, other: Any) -> "GTag":
|
|
549
550
|
return self.add(other)
|
|
550
551
|
|
|
551
|
-
def __setattr__(self, name: str, value: Any) -> None:
|
|
552
|
-
"""
|
|
553
|
-
Magic attribute handling:
|
|
554
|
-
- Internal attributes (starting with _ and containing __) are set normally.
|
|
555
|
-
- Public names (not starting with _) are set normally.
|
|
556
|
-
- Attributes starting with '_' (HTML-mapped) are treated as HTML attributes.
|
|
557
|
-
- Attributes starting with '_on' are treated as event callbacks.
|
|
558
|
-
- Setting an HTML attribute or event marks the tag as 'dirty' for client-side update.
|
|
559
|
-
"""
|
|
560
|
-
if (name.startswith("_") and "__" in name) or name in (
|
|
561
|
-
"childs",
|
|
562
|
-
"parent",
|
|
563
|
-
"tag",
|
|
564
|
-
"id",
|
|
565
|
-
"_reload",
|
|
566
|
-
"_browser_cleanup",
|
|
567
|
-
):
|
|
568
|
-
super().__setattr__(name, value)
|
|
569
|
-
elif name.startswith("_on") and (callable(value) or isinstance(value, str)):
|
|
570
|
-
# Event (e.g., self._onclick = my_callback or self._onclick = "alert(1)")
|
|
571
|
-
print(f"DEPRECATION: Setting events via underscore prefix ('{name}') is deprecated. Use 'self[\"{name[1:]}\"] = ...' instead.")
|
|
572
|
-
with self.__lock:
|
|
573
|
-
self.__events[name[3:]] = value
|
|
574
|
-
self.__dirty = True
|
|
575
|
-
elif name.startswith("_"):
|
|
576
|
-
# HTML attribute (e.g., self._class = "foo")
|
|
577
|
-
print(f"DEPRECATION: Setting HTML attributes via underscore prefix ('{name}') is deprecated. Use 'self[\"{name[1:].replace('_', '-')}\"] = ...' instead.")
|
|
578
|
-
attr_name = name[1:].replace("_", "-")
|
|
579
|
-
with self.__lock:
|
|
580
|
-
self.__attrs[attr_name] = value
|
|
581
|
-
self.__dirty = True
|
|
582
|
-
else:
|
|
583
|
-
# Regular Python attribute
|
|
584
|
-
super().__setattr__(name, value)
|
|
585
|
-
|
|
586
|
-
def __getattr__(self, name: str) -> Any:
|
|
587
|
-
if name in ("_reload", "_browser_cleanup"):
|
|
588
|
-
return super().__getattribute__(name)
|
|
589
|
-
if name.startswith("_") and "__" not in name:
|
|
590
|
-
if name.startswith("_on"):
|
|
591
|
-
event_name = name[3:]
|
|
592
|
-
events = super().__getattribute__("_GTag__events")
|
|
593
|
-
if event_name in events:
|
|
594
|
-
print(f"DEPRECATION: Accessing events via underscore prefix ('{name}') is deprecated. Use 'self[\"{name[1:]}\"]' instead.")
|
|
595
|
-
return events[event_name]
|
|
596
|
-
else:
|
|
597
|
-
try:
|
|
598
|
-
# Use super().__getattribute__ to avoid recursion loop with __getattr__
|
|
599
|
-
attrs = super().__getattribute__("_GTag__attrs")
|
|
600
|
-
attr_name = name[1:].replace("_", "-")
|
|
601
|
-
if attr_name in attrs:
|
|
602
|
-
print(f"DEPRECATION: Accessing HTML attributes via underscore prefix ('{name}') is deprecated. Use 'self[\"{attr_name}\"]' instead.")
|
|
603
|
-
return attrs[attr_name]
|
|
604
|
-
except AttributeError:
|
|
605
|
-
pass
|
|
606
|
-
return super().__getattribute__(name)
|
|
607
|
-
|
|
608
552
|
def __getitem__(self, name: str) -> Any:
|
|
609
|
-
if
|
|
610
|
-
|
|
611
|
-
|
|
553
|
+
if isinstance(name, str):
|
|
554
|
+
if name.startswith("on") and name[2:] in self.__events:
|
|
555
|
+
return self.__events[name[2:]]
|
|
556
|
+
return self.__attrs[name]
|
|
557
|
+
raise TypeError(f"GTag indices must be strings, not {type(name).__name__}")
|
|
612
558
|
|
|
613
559
|
def __setitem__(self, name: str, value: Any) -> None:
|
|
614
|
-
if name.startswith("on") and (callable(value) or isinstance(value, str)):
|
|
560
|
+
if isinstance(name, str) and name.startswith("on") and (callable(value) or isinstance(value, str)):
|
|
615
561
|
with self.__lock:
|
|
616
562
|
self.__events[name[2:]] = value
|
|
617
563
|
self.__dirty = True
|
|
@@ -621,7 +567,7 @@ class GTag: # aka "Generic Tag"
|
|
|
621
567
|
self.__dirty = True
|
|
622
568
|
|
|
623
569
|
def __delitem__(self, name: str) -> None:
|
|
624
|
-
if name.startswith("on") and name[2:] in self.__events:
|
|
570
|
+
if isinstance(name, str) and name.startswith("on") and name[2:] in self.__events:
|
|
625
571
|
with self.__lock:
|
|
626
572
|
del self.__events[name[2:]]
|
|
627
573
|
self.__dirty = True
|
|
@@ -694,7 +640,7 @@ class GTag: # aka "Generic Tag"
|
|
|
694
640
|
self.clear()
|
|
695
641
|
self.add(str(value))
|
|
696
642
|
|
|
697
|
-
def clear(self) -> "GTag":
|
|
643
|
+
def clear(self, *content: Any) -> "GTag":
|
|
698
644
|
with self.__lock:
|
|
699
645
|
for child in self.childs:
|
|
700
646
|
if isinstance(child, GTag):
|
|
@@ -704,6 +650,9 @@ class GTag: # aka "Generic Tag"
|
|
|
704
650
|
self.childs = []
|
|
705
651
|
self.__rendered_callables.clear()
|
|
706
652
|
self.__dirty = True
|
|
653
|
+
|
|
654
|
+
if content:
|
|
655
|
+
self.add(*content)
|
|
707
656
|
return self
|
|
708
657
|
|
|
709
658
|
def _update_classes(self, fn: Callable[[list[str]], None]) -> "GTag":
|
|
@@ -786,6 +735,8 @@ class GTag: # aka "Generic Tag"
|
|
|
786
735
|
def collect(item: Any) -> None:
|
|
787
736
|
if isinstance(item, GTag):
|
|
788
737
|
item.parent = self
|
|
738
|
+
if self.root is not None:
|
|
739
|
+
item._trigger_mount()
|
|
789
740
|
tags.append(item)
|
|
790
741
|
elif isinstance(item, (list, tuple)):
|
|
791
742
|
for i in item:
|
|
@@ -354,7 +354,7 @@ class AppRunner(BaseApp):
|
|
|
354
354
|
self._walk_tree(tag, visitor)
|
|
355
355
|
|
|
356
356
|
async def handle_event(self, msg: dict[str, Any], ws: WebSocket | None) -> None:
|
|
357
|
-
|
|
357
|
+
logger.info(f"handle_event {msg}")
|
|
358
358
|
tag_id: str | None = msg.get("id")
|
|
359
359
|
event_name: str | None = msg.get("event")
|
|
360
360
|
|
|
@@ -406,7 +406,7 @@ class AppRunner(BaseApp):
|
|
|
406
406
|
res = True # Convert to a simple truthy value
|
|
407
407
|
|
|
408
408
|
# Final broadcast after callback finishes, including the result if any
|
|
409
|
-
await self.broadcast_updates(result=res, callback_id=callback_id)
|
|
409
|
+
await self.broadcast_updates(result=res, callback_id=callback_id, ws=ws)
|
|
410
410
|
except Exception as e:
|
|
411
411
|
error_trace: str = traceback.format_exc()
|
|
412
412
|
error_msg: str = (
|
|
@@ -439,11 +439,19 @@ class AppRunner(BaseApp):
|
|
|
439
439
|
|
|
440
440
|
return
|
|
441
441
|
else:
|
|
442
|
-
|
|
443
|
-
|
|
442
|
+
await self.broadcast_updates(result=None, callback_id=callback_id, ws=ws)
|
|
443
|
+
else:
|
|
444
|
+
# tag not found (or missing tag_id)
|
|
445
|
+
# if we have a callback_id, we MUST respond to resolve the client-side promise
|
|
446
|
+
data = msg.get("data", {})
|
|
447
|
+
callback_id = (
|
|
448
|
+
data.get("callback_id") if isinstance(data, dict) else None
|
|
449
|
+
)
|
|
450
|
+
if callback_id:
|
|
451
|
+
await self.broadcast_updates(result=None, callback_id=callback_id, ws=ws)
|
|
444
452
|
|
|
445
453
|
async def broadcast_updates(
|
|
446
|
-
self, result: Any = None, callback_id: str | None = None
|
|
454
|
+
self, result: Any = None, callback_id: str | None = None, ws: WebSocket | None = None
|
|
447
455
|
) -> None:
|
|
448
456
|
"""
|
|
449
457
|
Collects all pending updates (tags, JS calls, statics)
|
|
@@ -475,7 +483,7 @@ class AppRunner(BaseApp):
|
|
|
475
483
|
|
|
476
484
|
# Send to websocket clients
|
|
477
485
|
dead_ws: list[WebSocket] = []
|
|
478
|
-
for client in list(self.websockets):
|
|
486
|
+
for client in list(self.websockets) + ([ws] if ws and ws not in self.websockets else []):
|
|
479
487
|
try:
|
|
480
488
|
await client.send_text(err_payload)
|
|
481
489
|
except Exception:
|
|
@@ -517,7 +525,7 @@ class AppRunner(BaseApp):
|
|
|
517
525
|
|
|
518
526
|
# Send to websocket clients
|
|
519
527
|
dead_ws_clients: list[WebSocket] = []
|
|
520
|
-
for client in list(self.websockets):
|
|
528
|
+
for client in list(self.websockets) + ([ws] if ws and ws not in self.websockets else []):
|
|
521
529
|
try:
|
|
522
530
|
await client.send_text(payload)
|
|
523
531
|
except Exception:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: htag
|
|
3
|
-
Version: 2.0.
|
|
3
|
+
Version: 2.0.5
|
|
4
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
|
|
@@ -103,7 +103,7 @@ htag is a Python library for building web applications using HTML, CSS, and Java
|
|
|
103
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.
|
|
104
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.
|
|
105
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.
|
|
106
|
-
* **Modern Attribute Access (Dictionary Style)**: In addition to `Tag.div(_class="foo")` in constructors, you
|
|
106
|
+
* **Modern Attribute Access (Dictionary Style)**: In addition to `Tag.div(_class="foo")` in constructors, you must now use `self["class"] = "foo"` for dynamic property management after instantiation. This is the only way to handle all attributes, including those with dashes (e.g., `self["data-id"] = 123`).
|
|
107
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.
|
|
108
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.
|
|
109
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.
|
|
@@ -9,7 +9,7 @@ include-package-data = true
|
|
|
9
9
|
|
|
10
10
|
[project]
|
|
11
11
|
name = "htag"
|
|
12
|
-
version = "2.0.
|
|
12
|
+
version = "2.0.5"
|
|
13
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"
|
|
@@ -47,6 +47,7 @@ dev = [
|
|
|
47
47
|
"pytest-asyncio>=1.3.0",
|
|
48
48
|
"pytest-cov>=7.0.0",
|
|
49
49
|
"pytest-playwright>=0.7.2",
|
|
50
|
+
"pytest-timeout>=2.4.0",
|
|
50
51
|
"ruff>=0.15.4",
|
|
51
52
|
]
|
|
52
53
|
|
|
@@ -40,25 +40,38 @@ def test_gtag_clear():
|
|
|
40
40
|
assert len(t.childs) == 0
|
|
41
41
|
assert t._GTag__dirty is True
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
t
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
assert t.
|
|
48
|
-
assert t.
|
|
49
|
-
|
|
50
|
-
t._class = "bar"
|
|
51
|
-
assert t._GTag__attrs["class"] == "bar"
|
|
43
|
+
# Test clear with arguments
|
|
44
|
+
t.add("one", "two")
|
|
45
|
+
t._GTag__dirty = False
|
|
46
|
+
t.clear("three", Tag.span("four"))
|
|
47
|
+
assert len(t.childs) == 2
|
|
48
|
+
assert t.childs[0] == "three"
|
|
49
|
+
assert t.childs[1].tag == "span"
|
|
52
50
|
assert t._GTag__dirty is True
|
|
51
|
+
|
|
52
|
+
# def test_gtag_attr_magic():
|
|
53
|
+
# t = Tag.div(_class="foo", _data_id="123")
|
|
54
|
+
|
|
55
|
+
# # Initialization still sets HTML attributes (via kwargs)
|
|
56
|
+
# assert t["class"] == "foo"
|
|
57
|
+
# assert t["data-id"] == "123"
|
|
58
|
+
# assert t.id is not None
|
|
59
|
+
|
|
60
|
+
# # Setting an attribute with an underscore should now just be a regular python attribute
|
|
61
|
+
# t._class = "bar"
|
|
62
|
+
# assert getattr(t, "_class") == "bar"
|
|
53
63
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
assert t.some_var == 42
|
|
64
|
+
# # It should not affect the HTML attribute
|
|
65
|
+
# assert t["class"] == "foo"
|
|
57
66
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
67
|
+
# # Regular python attribute
|
|
68
|
+
# t.some_var = 42
|
|
69
|
+
# assert t.some_var == 42
|
|
70
|
+
|
|
71
|
+
# # Test line 96: event setter
|
|
72
|
+
# def other_h(e): pass
|
|
73
|
+
# t._onmouseover = other_h
|
|
74
|
+
# assert "mouseover" in t._GTag__events
|
|
62
75
|
|
|
63
76
|
def test_gtag_render_attrs():
|
|
64
77
|
t = Tag.div(_class="foo", _data_id="123")
|
|
@@ -136,11 +149,11 @@ def test_add_class():
|
|
|
136
149
|
# Test lines 140-146
|
|
137
150
|
t = Tag.div()
|
|
138
151
|
t.add_class("foo")
|
|
139
|
-
assert t
|
|
152
|
+
assert t["class"] == "foo"
|
|
140
153
|
t.add_class("bar")
|
|
141
|
-
assert t
|
|
154
|
+
assert t["class"] == "foo bar"
|
|
142
155
|
t.add_class("foo") # already there
|
|
143
|
-
assert t
|
|
156
|
+
assert t["class"] == "foo bar"
|
|
144
157
|
|
|
145
158
|
def test_remove_class():
|
|
146
159
|
t = Tag.div(_class="foo bar")
|
|
@@ -148,17 +161,17 @@ def test_remove_class():
|
|
|
148
161
|
# Remove existing class
|
|
149
162
|
class_self = t.remove_class("foo")
|
|
150
163
|
assert class_self is t
|
|
151
|
-
assert t
|
|
164
|
+
assert t["class"] == "bar"
|
|
152
165
|
|
|
153
166
|
# Remove non-existing class
|
|
154
167
|
t._GTag__dirty = False
|
|
155
168
|
t.remove_class("baz")
|
|
156
|
-
assert t
|
|
169
|
+
assert t["class"] == "bar"
|
|
157
170
|
assert t._GTag__dirty is False # Shouldn't be marked dirty if nothing was removed
|
|
158
171
|
|
|
159
172
|
# Remove last class
|
|
160
173
|
t.remove_class("bar")
|
|
161
|
-
assert t
|
|
174
|
+
assert t["class"] == ""
|
|
162
175
|
|
|
163
176
|
def test_gtag_iadd():
|
|
164
177
|
t = Tag.div()
|
|
@@ -289,15 +302,15 @@ def test_toggle_class():
|
|
|
289
302
|
# Toggle off existing class
|
|
290
303
|
result = t.toggle_class("foo")
|
|
291
304
|
assert result is t # Returns self (chainable)
|
|
292
|
-
assert t
|
|
305
|
+
assert t["class"] == "bar"
|
|
293
306
|
|
|
294
307
|
# Toggle on missing class
|
|
295
308
|
t.toggle_class("baz")
|
|
296
|
-
assert t
|
|
309
|
+
assert t["class"] == "bar baz"
|
|
297
310
|
|
|
298
311
|
# Toggle off again
|
|
299
312
|
t.toggle_class("baz")
|
|
300
|
-
assert t
|
|
313
|
+
assert t["class"] == "bar"
|
|
301
314
|
|
|
302
315
|
def test_has_class():
|
|
303
316
|
t = Tag.div(_class="foo bar")
|
|
@@ -640,3 +653,140 @@ def test_state_repr_str():
|
|
|
640
653
|
s2 = State("hello")
|
|
641
654
|
assert str(s2) == "hello"
|
|
642
655
|
assert repr(s2) == "'hello'"
|
|
656
|
+
|
|
657
|
+
def test_state_inplace_operators():
|
|
658
|
+
s = State(10)
|
|
659
|
+
s += 5; assert s.value == 15
|
|
660
|
+
s -= 5; assert s.value == 10
|
|
661
|
+
s *= 2; assert s.value == 20
|
|
662
|
+
s /= 2; assert s.value == 10.0
|
|
663
|
+
s //= 3; assert s.value == 3.0
|
|
664
|
+
s %= 2; assert s.value == 1.0
|
|
665
|
+
s **= 2; assert s.value == 1.0
|
|
666
|
+
|
|
667
|
+
s = State(1)
|
|
668
|
+
s <<= 1; assert s.value == 2
|
|
669
|
+
s >>= 1; assert s.value == 1
|
|
670
|
+
s &= 1; assert s.value == 1
|
|
671
|
+
s ^= 0; assert s.value == 1
|
|
672
|
+
s |= 2; assert s.value == 3
|
|
673
|
+
|
|
674
|
+
def test_state_fallback_setattr():
|
|
675
|
+
class Dummy: pass
|
|
676
|
+
d = Dummy()
|
|
677
|
+
s = State(d)
|
|
678
|
+
s.foo = "bar"
|
|
679
|
+
assert d.foo == "bar"
|
|
680
|
+
|
|
681
|
+
# Coverage for line 73: AttributeError fallback
|
|
682
|
+
s2 = State(42) # int doesn't have __dict__ generally
|
|
683
|
+
s2.not_an_attr = "val"
|
|
684
|
+
assert s2.not_an_attr == "val"
|
|
685
|
+
|
|
686
|
+
def test_state_proxy_advanced():
|
|
687
|
+
s = State([1, 2])
|
|
688
|
+
proxy = s[0] # Not actually a proxy for an int, it wraps the container
|
|
689
|
+
|
|
690
|
+
# Test with a dict
|
|
691
|
+
s = State({"a": 1})
|
|
692
|
+
proxy = s._wrap(s.value)
|
|
693
|
+
|
|
694
|
+
# getattr / setattr / delattr on proxy
|
|
695
|
+
class Obj:
|
|
696
|
+
def method(self): return 42
|
|
697
|
+
o = Obj()
|
|
698
|
+
s_obj = State(o)
|
|
699
|
+
proxy_obj = s_obj._wrap(o)
|
|
700
|
+
assert proxy_obj.method() == 42
|
|
701
|
+
|
|
702
|
+
proxy_obj.x = 10
|
|
703
|
+
assert o.x == 10
|
|
704
|
+
del proxy_obj.x
|
|
705
|
+
assert not hasattr(o, "x")
|
|
706
|
+
|
|
707
|
+
# __delitem__ / __len__ / __iter__ / __contains__
|
|
708
|
+
s_list = State([10, 20])
|
|
709
|
+
p_list = s_list._wrap(s_list.value)
|
|
710
|
+
del p_list[0]
|
|
711
|
+
assert p_list() == [20]
|
|
712
|
+
assert len(p_list) == 1
|
|
713
|
+
assert list(iter(p_list)) == [20]
|
|
714
|
+
assert 20 in p_list
|
|
715
|
+
|
|
716
|
+
# operators
|
|
717
|
+
assert p_list == [20]
|
|
718
|
+
assert p_list < [30]
|
|
719
|
+
assert p_list + [30] == [20, 30]
|
|
720
|
+
|
|
721
|
+
# __call__ observer reg
|
|
722
|
+
t = Tag.div()
|
|
723
|
+
from htag.core import _ctx
|
|
724
|
+
_ctx.current_eval = t
|
|
725
|
+
p_list()
|
|
726
|
+
assert t in s_list._observers
|
|
727
|
+
_ctx.current_eval = None
|
|
728
|
+
|
|
729
|
+
def test_gtag_edge_cases():
|
|
730
|
+
# Tag(None) -> fragment
|
|
731
|
+
t = GTag(None, "content")
|
|
732
|
+
assert t.tag is None
|
|
733
|
+
assert str(t) == "content"
|
|
734
|
+
|
|
735
|
+
# statics as single object + styles to trigger list conversion in __init__
|
|
736
|
+
class StaticTag(GTag):
|
|
737
|
+
statics = Tag.style(".foo{}")
|
|
738
|
+
styles = ".bar {color:red}"
|
|
739
|
+
st = StaticTag()
|
|
740
|
+
# Now statics is the list [original_static, scoped_static]
|
|
741
|
+
all_statics = getattr(StaticTag, "statics")
|
|
742
|
+
assert isinstance(all_statics, list)
|
|
743
|
+
assert any(".foo{}" in str(s) for s in all_statics)
|
|
744
|
+
|
|
745
|
+
# add(None)
|
|
746
|
+
t.add(None)
|
|
747
|
+
assert len(t.childs) == 1 # still just "content"
|
|
748
|
+
|
|
749
|
+
# __getattr__ missing
|
|
750
|
+
try:
|
|
751
|
+
t.non_existent
|
|
752
|
+
except AttributeError:
|
|
753
|
+
pass
|
|
754
|
+
|
|
755
|
+
# __getitem__ TypeError
|
|
756
|
+
try:
|
|
757
|
+
t[0]
|
|
758
|
+
except TypeError:
|
|
759
|
+
pass
|
|
760
|
+
|
|
761
|
+
# __delitem__ KeyError
|
|
762
|
+
try:
|
|
763
|
+
del t["missing"]
|
|
764
|
+
except KeyError:
|
|
765
|
+
pass
|
|
766
|
+
|
|
767
|
+
def test_gtag_reactive_lifecycle():
|
|
768
|
+
# Test that components inside lambdas trigger lifecycle
|
|
769
|
+
c = Tag.span()
|
|
770
|
+
mounted = False
|
|
771
|
+
def on_m(): nonlocal mounted; mounted = True
|
|
772
|
+
c.on_mount = on_m
|
|
773
|
+
|
|
774
|
+
app = Tag.App()
|
|
775
|
+
t = Tag.div(lambda: c)
|
|
776
|
+
app.add(t)
|
|
777
|
+
|
|
778
|
+
# Trigger render
|
|
779
|
+
str(app)
|
|
780
|
+
assert c.root is app
|
|
781
|
+
assert mounted is True
|
|
782
|
+
|
|
783
|
+
def test_gtag_reactive_list_none():
|
|
784
|
+
t = Tag.div(lambda: [Tag.b("1"), Tag.i("2")])
|
|
785
|
+
rendered = str(t)
|
|
786
|
+
assert "<b" in rendered
|
|
787
|
+
assert ">1</b>" in rendered
|
|
788
|
+
assert "<i" in rendered
|
|
789
|
+
assert ">2</i>" in rendered
|
|
790
|
+
|
|
791
|
+
t2 = Tag.div(lambda: None)
|
|
792
|
+
assert str(t2).strip().endswith("></div>")
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import pytest
|
|
2
|
+
import logging
|
|
3
|
+
from htag import Tag
|
|
4
|
+
|
|
5
|
+
def test_instantiation_no_warning(capsys):
|
|
6
|
+
"""
|
|
7
|
+
Ensures that underscore-prefixed attributes during instantiation
|
|
8
|
+
do NOT trigger a deprecation warning.
|
|
9
|
+
"""
|
|
10
|
+
t = Tag.div(_class="myclass", _onclick="alert(1)")
|
|
11
|
+
captured = capsys.readouterr()
|
|
12
|
+
assert captured.out == ""
|
|
13
|
+
assert t["class"] == "myclass"
|
|
14
|
+
assert t["onclick"] == "alert(1)"
|
|
15
|
+
|
|
16
|
+
def test_instantiation_no_warning(capsys):
|
|
17
|
+
"""
|
|
18
|
+
Ensures that underscore-prefixed attributes during instantiation
|
|
19
|
+
do NOT trigger a deprecation warning anymore AND correctly map to HTML attributes.
|
|
20
|
+
"""
|
|
21
|
+
t = Tag.div(_class="myclass", _onclick="alert(1)")
|
|
22
|
+
captured = capsys.readouterr()
|
|
23
|
+
assert captured.out == ""
|
|
24
|
+
assert t["class"] == "myclass"
|
|
25
|
+
assert t["onclick"] == "alert(1)"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_getattr_no_warning_on_missing(capsys):
|
|
29
|
+
"""
|
|
30
|
+
Accessing a non-existent underscore attribute should still raise AttributeError or return what super does,
|
|
31
|
+
but shouldn't warn if it doesn't exist in attrs/events.
|
|
32
|
+
"""
|
|
33
|
+
t = Tag.div()
|
|
34
|
+
_ = capsys.readouterr() # clear
|
|
35
|
+
with pytest.raises(AttributeError):
|
|
36
|
+
_ = t._non_existent
|
|
37
|
+
captured = capsys.readouterr()
|
|
38
|
+
assert captured.out == ""
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import pytest
|
|
3
|
+
from htag import Tag
|
|
4
|
+
from htag.runner import AppRunner
|
|
5
|
+
|
|
6
|
+
class MyTag(Tag.div):
|
|
7
|
+
def init(self):
|
|
8
|
+
self.count = 0
|
|
9
|
+
def click(self):
|
|
10
|
+
self.count += 1
|
|
11
|
+
return self.count
|
|
12
|
+
|
|
13
|
+
@pytest.mark.asyncio
|
|
14
|
+
async def test_handle_event_invalid_tag_responds():
|
|
15
|
+
app = AppRunner(MyTag)
|
|
16
|
+
|
|
17
|
+
# Simulate an event to a non-existent tag ID
|
|
18
|
+
msg = {
|
|
19
|
+
"id": "non-existent-id",
|
|
20
|
+
"event": "click",
|
|
21
|
+
"data": {"callback_id": "cb123"}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
responses = []
|
|
25
|
+
class MockWS:
|
|
26
|
+
async def send_text(self, text):
|
|
27
|
+
responses.append(text)
|
|
28
|
+
async def accept(self): pass
|
|
29
|
+
async def receive_text(self): pass
|
|
30
|
+
|
|
31
|
+
await app.handle_event(msg, MockWS())
|
|
32
|
+
|
|
33
|
+
assert len(responses) == 1
|
|
34
|
+
import json
|
|
35
|
+
from htag.utils import _obf_loads
|
|
36
|
+
resp = _obf_loads(responses[0], None)
|
|
37
|
+
assert resp["action"] == "update"
|
|
38
|
+
assert resp["callback_id"] == "cb123"
|
|
39
|
+
assert resp["result"] is None
|
|
40
|
+
|
|
41
|
+
@pytest.mark.asyncio
|
|
42
|
+
async def test_handle_event_missing_event_responds():
|
|
43
|
+
app = AppRunner(MyTag)
|
|
44
|
+
|
|
45
|
+
# Tag exists but event doesn't
|
|
46
|
+
tag = MyTag()
|
|
47
|
+
app.add(tag)
|
|
48
|
+
|
|
49
|
+
msg = {
|
|
50
|
+
"id": tag.id,
|
|
51
|
+
"event": "unknown_event",
|
|
52
|
+
"data": {"callback_id": "cb456"}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
responses = []
|
|
56
|
+
class MockWS:
|
|
57
|
+
async def send_text(self, text):
|
|
58
|
+
responses.append(text)
|
|
59
|
+
|
|
60
|
+
await app.handle_event(msg, MockWS())
|
|
61
|
+
|
|
62
|
+
assert len(responses) == 1
|
|
63
|
+
from htag.utils import _obf_loads
|
|
64
|
+
resp = _obf_loads(responses[0], None)
|
|
65
|
+
assert resp["callback_id"] == "cb456"
|
|
@@ -61,3 +61,22 @@ def test_webapp_parano_mode():
|
|
|
61
61
|
assert resp_post.status_code == 200
|
|
62
62
|
assert resp_post.json() == {"status": "ok"}
|
|
63
63
|
|
|
64
|
+
def test_webapp_csrf_failure():
|
|
65
|
+
app_host = WebApp(MyApp)
|
|
66
|
+
client = TestClient(app_host.app)
|
|
67
|
+
|
|
68
|
+
# Get session
|
|
69
|
+
response = client.get("/")
|
|
70
|
+
cookies = response.cookies
|
|
71
|
+
|
|
72
|
+
# Try to POST without X-HTAG-TOKEN
|
|
73
|
+
payload = {"id": "b1", "event": "click"}
|
|
74
|
+
resp = client.post("/event", json=payload)
|
|
75
|
+
assert resp.status_code == 403
|
|
76
|
+
assert "CSRF Token mismatch" in resp.text
|
|
77
|
+
|
|
78
|
+
# Try with WRONG token
|
|
79
|
+
headers = {"X-HTAG-TOKEN": "wrong_token"}
|
|
80
|
+
resp = client.post("/event", json=payload, headers=headers)
|
|
81
|
+
assert resp.status_code == 403
|
|
82
|
+
|
|
@@ -89,7 +89,7 @@ async def test_generator_event_handler():
|
|
|
89
89
|
app = MyApp()
|
|
90
90
|
# We need to mock broadcast_updates to avoid network calls
|
|
91
91
|
broadcasts = []
|
|
92
|
-
async def mock_broadcast(result=None, callback_id=None):
|
|
92
|
+
async def mock_broadcast(result=None, callback_id=None, **kwargs):
|
|
93
93
|
broadcasts.append((result, callback_id))
|
|
94
94
|
app.broadcast_updates = mock_broadcast
|
|
95
95
|
|
|
@@ -122,7 +122,7 @@ async def test_async_generator_event_handler():
|
|
|
122
122
|
|
|
123
123
|
app = MyApp()
|
|
124
124
|
broadcasts = []
|
|
125
|
-
async def mock_broadcast(result=None, callback_id=None):
|
|
125
|
+
async def mock_broadcast(result=None, callback_id=None, **kwargs):
|
|
126
126
|
broadcasts.append((result, callback_id))
|
|
127
127
|
app.broadcast_updates = mock_broadcast
|
|
128
128
|
|
|
@@ -153,6 +153,23 @@ async def test_app_handle_event_error():
|
|
|
153
153
|
assert "boom" in data["traceback"]
|
|
154
154
|
assert data["callback_id"] == "error1"
|
|
155
155
|
|
|
156
|
+
@pytest.mark.asyncio
|
|
157
|
+
async def test_app_handle_event_error_stdout(capsys):
|
|
158
|
+
app = App()
|
|
159
|
+
def fail(e):
|
|
160
|
+
raise ValueError("stdout boom")
|
|
161
|
+
|
|
162
|
+
btn = Tag.button(_onclick=fail)
|
|
163
|
+
app += btn
|
|
164
|
+
|
|
165
|
+
ws = AsyncMock()
|
|
166
|
+
msg = {"id": btn.id, "event": "click", "data": {}}
|
|
167
|
+
|
|
168
|
+
await app.handle_event(msg, ws)
|
|
169
|
+
|
|
170
|
+
captured = capsys.readouterr()
|
|
171
|
+
assert "ValueError: stdout boom" in captured.out
|
|
172
|
+
|
|
156
173
|
@pytest.mark.asyncio
|
|
157
174
|
async def test_app_handle_event_async_error():
|
|
158
175
|
app = App()
|
|
@@ -269,7 +286,7 @@ async def test_app_handle_event_value_sync():
|
|
|
269
286
|
ws = AsyncMock()
|
|
270
287
|
msg = {"id": inp.id, "event": "input", "data": {"value": "new"}}
|
|
271
288
|
await app.handle_event(msg, ws)
|
|
272
|
-
assert inp
|
|
289
|
+
assert inp["value"] == "new"
|
|
273
290
|
|
|
274
291
|
@pytest.mark.asyncio
|
|
275
292
|
async def test_app_handle_event_async_generator():
|
|
@@ -42,7 +42,7 @@ async def test_app_handle_simple_event():
|
|
|
42
42
|
# We simulate a tag that has a custom event handler
|
|
43
43
|
class MyTag(Tag.div):
|
|
44
44
|
def init(self):
|
|
45
|
-
self
|
|
45
|
+
self["oncustom"] = my_cb
|
|
46
46
|
|
|
47
47
|
tag = MyTag()
|
|
48
48
|
app += tag
|
|
@@ -66,7 +66,7 @@ async def test_app_handle_hashchange_event():
|
|
|
66
66
|
shared["new"] = e.newURL
|
|
67
67
|
shared["old"] = e.oldURL
|
|
68
68
|
|
|
69
|
-
app
|
|
69
|
+
app["onhashchange"] = on_hash
|
|
70
70
|
|
|
71
71
|
ws = AsyncMock()
|
|
72
72
|
msg = {
|
|
@@ -129,7 +129,7 @@ async def test_app_handle_form_event():
|
|
|
129
129
|
|
|
130
130
|
class MyForm(Tag.form):
|
|
131
131
|
def init(self):
|
|
132
|
-
self
|
|
132
|
+
self["onsubmit"] = on_submit
|
|
133
133
|
|
|
134
134
|
tag = MyForm()
|
|
135
135
|
app += tag
|
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
import pytest
|
|
2
|
-
import logging
|
|
3
|
-
from htag import Tag
|
|
4
|
-
|
|
5
|
-
def test_instantiation_no_warning(capsys):
|
|
6
|
-
"""
|
|
7
|
-
Ensures that underscore-prefixed attributes during instantiation
|
|
8
|
-
do NOT trigger a deprecation warning.
|
|
9
|
-
"""
|
|
10
|
-
t = Tag.div(_class="myclass", _onclick="alert(1)")
|
|
11
|
-
captured = capsys.readouterr()
|
|
12
|
-
assert captured.out == ""
|
|
13
|
-
assert t["class"] == "myclass"
|
|
14
|
-
assert t["onclick"] == "alert(1)"
|
|
15
|
-
|
|
16
|
-
def test_getattr_warning(capsys):
|
|
17
|
-
"""
|
|
18
|
-
Ensures that accessing underscore-prefixed attributes triggers a warning.
|
|
19
|
-
"""
|
|
20
|
-
t = Tag.div(_class="myclass")
|
|
21
|
-
_ = capsys.readouterr() # clear
|
|
22
|
-
c = t._class
|
|
23
|
-
assert c == "myclass"
|
|
24
|
-
captured = capsys.readouterr()
|
|
25
|
-
assert "DEPRECATION" in captured.out
|
|
26
|
-
assert "Accessing HTML attributes via underscore prefix ('_class')" in captured.out
|
|
27
|
-
|
|
28
|
-
def test_setattr_warning(capsys):
|
|
29
|
-
"""
|
|
30
|
-
Ensures that setting underscore-prefixed attributes triggers a warning.
|
|
31
|
-
"""
|
|
32
|
-
t = Tag.div()
|
|
33
|
-
_ = capsys.readouterr() # clear
|
|
34
|
-
t._class = "newclass"
|
|
35
|
-
assert t["class"] == "newclass"
|
|
36
|
-
captured = capsys.readouterr()
|
|
37
|
-
assert "DEPRECATION" in captured.out
|
|
38
|
-
assert "Setting HTML attributes via underscore prefix ('_class')" in captured.out
|
|
39
|
-
|
|
40
|
-
def test_get_event_warning(capsys):
|
|
41
|
-
"""
|
|
42
|
-
Ensures that accessing underscore-prefixed events triggers a warning.
|
|
43
|
-
"""
|
|
44
|
-
t = Tag.div(_onclick="alert(1)")
|
|
45
|
-
_ = capsys.readouterr() # clear
|
|
46
|
-
oc = t._onclick
|
|
47
|
-
assert oc == "alert(1)"
|
|
48
|
-
captured = capsys.readouterr()
|
|
49
|
-
assert "DEPRECATION" in captured.out
|
|
50
|
-
assert "Accessing events via underscore prefix ('_onclick')" in captured.out
|
|
51
|
-
|
|
52
|
-
def test_set_event_warning(capsys):
|
|
53
|
-
"""
|
|
54
|
-
Ensures that setting underscore-prefixed events triggers a warning.
|
|
55
|
-
"""
|
|
56
|
-
t = Tag.div()
|
|
57
|
-
_ = capsys.readouterr() # clear
|
|
58
|
-
t._onclick = "alert(2)"
|
|
59
|
-
assert t["onclick"] == "alert(2)"
|
|
60
|
-
captured = capsys.readouterr()
|
|
61
|
-
assert "DEPRECATION" in captured.out
|
|
62
|
-
assert "Setting events via underscore prefix ('_onclick')" in captured.out
|
|
63
|
-
|
|
64
|
-
def test_getattr_no_warning_on_missing(capsys):
|
|
65
|
-
"""
|
|
66
|
-
Accessing a non-existent underscore attribute should still raise AttributeError or return what super does,
|
|
67
|
-
but shouldn't warn if it doesn't exist in attrs/events.
|
|
68
|
-
"""
|
|
69
|
-
t = Tag.div()
|
|
70
|
-
_ = capsys.readouterr() # clear
|
|
71
|
-
with pytest.raises(AttributeError):
|
|
72
|
-
_ = t._non_existent
|
|
73
|
-
captured = capsys.readouterr()
|
|
74
|
-
assert captured.out == ""
|
|
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
|