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.
Files changed (47) hide show
  1. {htag-2.0.3 → htag-2.0.5}/PKG-INFO +2 -2
  2. {htag-2.0.3 → htag-2.0.5}/README.md +1 -1
  3. {htag-2.0.3 → htag-2.0.5}/htag/client_js.py +21 -19
  4. {htag-2.0.3 → htag-2.0.5}/htag/core.py +16 -65
  5. {htag-2.0.3 → htag-2.0.5}/htag/runner.py +15 -7
  6. {htag-2.0.3 → htag-2.0.5}/htag.egg-info/PKG-INFO +2 -2
  7. {htag-2.0.3 → htag-2.0.5}/htag.egg-info/SOURCES.txt +1 -0
  8. {htag-2.0.3 → htag-2.0.5}/pyproject.toml +2 -1
  9. {htag-2.0.3 → htag-2.0.5}/tests/test_core.py +175 -25
  10. htag-2.0.5/tests/test_deprecation_warnings.py +38 -0
  11. htag-2.0.5/tests/test_interacting_robustness.py +65 -0
  12. {htag-2.0.3 → htag-2.0.5}/tests/test_parano.py +19 -0
  13. {htag-2.0.3 → htag-2.0.5}/tests/test_reactivity_edge_cases.py +2 -2
  14. {htag-2.0.3 → htag-2.0.5}/tests/test_server.py +18 -1
  15. {htag-2.0.3 → htag-2.0.5}/tests/test_simple_events.py +3 -3
  16. htag-2.0.3/tests/test_deprecation_warnings.py +0 -74
  17. {htag-2.0.3 → htag-2.0.5}/htag/__init__.py +0 -0
  18. {htag-2.0.3 → htag-2.0.5}/htag/cli.py +0 -0
  19. {htag-2.0.3 → htag-2.0.5}/htag/context.py +0 -0
  20. {htag-2.0.3 → htag-2.0.5}/htag/css.py +0 -0
  21. {htag-2.0.3 → htag-2.0.5}/htag/exceptions.py +0 -0
  22. {htag-2.0.3 → htag-2.0.5}/htag/logo.py +0 -0
  23. {htag-2.0.3 → htag-2.0.5}/htag/runners/__init__.py +0 -0
  24. {htag-2.0.3 → htag-2.0.5}/htag/runners/chromeapp.py +0 -0
  25. {htag-2.0.3 → htag-2.0.5}/htag/runners/pyscript.py +0 -0
  26. {htag-2.0.3 → htag-2.0.5}/htag/server.py +0 -0
  27. {htag-2.0.3 → htag-2.0.5}/htag/tag.py +0 -0
  28. {htag-2.0.3 → htag-2.0.5}/htag/utils.py +0 -0
  29. {htag-2.0.3 → htag-2.0.5}/htag/web.py +0 -0
  30. {htag-2.0.3 → htag-2.0.5}/htag.egg-info/dependency_links.txt +0 -0
  31. {htag-2.0.3 → htag-2.0.5}/htag.egg-info/entry_points.txt +0 -0
  32. {htag-2.0.3 → htag-2.0.5}/htag.egg-info/requires.txt +0 -0
  33. {htag-2.0.3 → htag-2.0.5}/htag.egg-info/top_level.txt +0 -0
  34. {htag-2.0.3 → htag-2.0.5}/setup.cfg +0 -0
  35. {htag-2.0.3 → htag-2.0.5}/tests/test_cmd.py +0 -0
  36. {htag-2.0.3 → htag-2.0.5}/tests/test_fallback.py +0 -0
  37. {htag-2.0.3 → htag-2.0.5}/tests/test_lifecycle.py +0 -0
  38. {htag-2.0.3 → htag-2.0.5}/tests/test_memory.py +0 -0
  39. {htag-2.0.3 → htag-2.0.5}/tests/test_runner_pyscript.py +0 -0
  40. {htag-2.0.3 → htag-2.0.5}/tests/test_runners_reload.py +0 -0
  41. {htag-2.0.3 → htag-2.0.5}/tests/test_state_advanced.py +0 -0
  42. {htag-2.0.3 → htag-2.0.5}/tests/test_state_features.py +0 -0
  43. {htag-2.0.3 → htag-2.0.5}/tests/test_state_proxy.py +0 -0
  44. {htag-2.0.3 → htag-2.0.5}/tests/test_state_reactivity.py +0 -0
  45. {htag-2.0.3 → htag-2.0.5}/tests/test_tag_names.py +0 -0
  46. {htag-2.0.3 → htag-2.0.5}/tests/test_web_sessions.py +0 -0
  47. {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
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 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`).
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 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`).
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
- var _interacting = 0;
9
- function _inc_interacting() {
10
- _interacting++;
11
- document.body.classList.add("interacting");
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
- _dec_interacting();
219
- window._htag_callbacks[data.callback_id](data.result);
220
- delete window._htag_callbacks[data.callback_id];
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
- _interacting = 0;
226
- document.body.classList.remove("interacting");
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 name.startswith("on") and name[2:] in self.__events:
610
- return self.__events[name[2:]]
611
- return self.__attrs[name]
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
- print(f"SERVERSIDE: handle_event {msg}")
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
- res = None
443
- await self.broadcast_updates(result=res, callback_id=callback_id)
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
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 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`).
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.
@@ -26,6 +26,7 @@ tests/test_cmd.py
26
26
  tests/test_core.py
27
27
  tests/test_deprecation_warnings.py
28
28
  tests/test_fallback.py
29
+ tests/test_interacting_robustness.py
29
30
  tests/test_lifecycle.py
30
31
  tests/test_memory.py
31
32
  tests/test_parano.py
@@ -9,7 +9,7 @@ include-package-data = true
9
9
 
10
10
  [project]
11
11
  name = "htag"
12
- version = "2.0.3"
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
- def test_gtag_attr_magic():
44
- t = Tag.div(_class="foo", _data_id="123")
45
- assert t._class == "foo"
46
- assert t._GTag__attrs["class"] == "foo"
47
- assert t._GTag__attrs["data-id"] == "123"
48
- assert t.id is not None
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
- # Test line 103: regular python attribute
55
- t.some_var = 42
56
- assert t.some_var == 42
64
+ # # It should not affect the HTML attribute
65
+ # assert t["class"] == "foo"
57
66
 
58
- # Test line 96: event setter
59
- def other_h(e): pass
60
- t._onmouseover = other_h
61
- assert "mouseover" in t._GTag__events
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._class == "foo"
152
+ assert t["class"] == "foo"
140
153
  t.add_class("bar")
141
- assert t._class == "foo bar"
154
+ assert t["class"] == "foo bar"
142
155
  t.add_class("foo") # already there
143
- assert t._class == "foo bar"
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._class == "bar"
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._class == "bar"
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._class == ""
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._class == "bar"
305
+ assert t["class"] == "bar"
293
306
 
294
307
  # Toggle on missing class
295
308
  t.toggle_class("baz")
296
- assert t._class == "bar baz"
309
+ assert t["class"] == "bar baz"
297
310
 
298
311
  # Toggle off again
299
312
  t.toggle_class("baz")
300
- assert t._class == "bar"
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._value == "new"
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._oncustom = my_cb
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._onhashchange = on_hash
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._onsubmit = on_submit
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