htag 2.0.5__tar.gz → 2.0.7__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.5 → htag-2.0.7}/PKG-INFO +1 -1
- {htag-2.0.5 → htag-2.0.7}/htag/client_js.py +35 -3
- {htag-2.0.5 → htag-2.0.7}/htag/core.py +4 -1
- {htag-2.0.5 → htag-2.0.7}/htag/runner.py +16 -2
- {htag-2.0.5 → htag-2.0.7}/htag.egg-info/PKG-INFO +1 -1
- {htag-2.0.5 → htag-2.0.7}/htag.egg-info/SOURCES.txt +1 -0
- {htag-2.0.5 → htag-2.0.7}/pyproject.toml +1 -1
- htag-2.0.7/tests/test_statics_deduplication.py +106 -0
- {htag-2.0.5 → htag-2.0.7}/README.md +0 -0
- {htag-2.0.5 → htag-2.0.7}/htag/__init__.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/htag/cli.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/htag/context.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/htag/css.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/htag/exceptions.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/htag/logo.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/htag/runners/__init__.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/htag/runners/chromeapp.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/htag/runners/pyscript.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/htag/server.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/htag/tag.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/htag/utils.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/htag/web.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/htag.egg-info/dependency_links.txt +0 -0
- {htag-2.0.5 → htag-2.0.7}/htag.egg-info/entry_points.txt +0 -0
- {htag-2.0.5 → htag-2.0.7}/htag.egg-info/requires.txt +0 -0
- {htag-2.0.5 → htag-2.0.7}/htag.egg-info/top_level.txt +0 -0
- {htag-2.0.5 → htag-2.0.7}/setup.cfg +0 -0
- {htag-2.0.5 → htag-2.0.7}/tests/test_cmd.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/tests/test_core.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/tests/test_deprecation_warnings.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/tests/test_fallback.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/tests/test_interacting_robustness.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/tests/test_lifecycle.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/tests/test_memory.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/tests/test_parano.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/tests/test_reactivity_edge_cases.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/tests/test_runner_pyscript.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/tests/test_runners_reload.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/tests/test_server.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/tests/test_simple_events.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/tests/test_state_advanced.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/tests/test_state_features.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/tests/test_state_proxy.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/tests/test_state_reactivity.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/tests/test_tag_names.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/tests/test_web_sessions.py +0 -0
- {htag-2.0.5 → htag-2.0.7}/tests/test_webapp_run.py +0 -0
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
|
|
1
|
+
def __minify_js(js_code: str) -> str:
|
|
2
|
+
import re
|
|
3
|
+
# Remove single line comments (but not URL schemes like http://)
|
|
4
|
+
js = re.sub(r'(?<!:)//.*', '', js_code)
|
|
5
|
+
# Remove newlines and tabs
|
|
6
|
+
js = re.sub(r'\s+', ' ', js).strip()
|
|
7
|
+
return js
|
|
8
|
+
|
|
9
|
+
CLIENT_JS = __minify_js("""
|
|
2
10
|
// The client-side bridge that connects the browser to the Python server.
|
|
3
11
|
var ws;
|
|
4
12
|
var use_fallback = false;
|
|
@@ -158,7 +166,30 @@ function handle_payload(data) {
|
|
|
158
166
|
// Apply partial DOM updates received from the server
|
|
159
167
|
for(var id in data.updates) {
|
|
160
168
|
var el = document.getElementById(id) || document.querySelector('[data-htag-id="' + id + '"]');
|
|
161
|
-
if(el)
|
|
169
|
+
if(el) {
|
|
170
|
+
if(el.tagName === 'BODY') {
|
|
171
|
+
var doc = new DOMParser().parseFromString(data.updates[id], 'text/html');
|
|
172
|
+
|
|
173
|
+
// Sync attributes properly
|
|
174
|
+
var newAttrNames = new Set();
|
|
175
|
+
for(var i = 0; i < doc.body.attributes.length; i++) {
|
|
176
|
+
var attr = doc.body.attributes[i];
|
|
177
|
+
el.setAttribute(attr.name, attr.value);
|
|
178
|
+
newAttrNames.add(attr.name);
|
|
179
|
+
}
|
|
180
|
+
// Remove old attributes that are not in the new body
|
|
181
|
+
for(var i = el.attributes.length - 1; i >= 0; i--) {
|
|
182
|
+
var attrName = el.attributes[i].name;
|
|
183
|
+
if(!newAttrNames.has(attrName)) {
|
|
184
|
+
el.removeAttribute(attrName);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
el.innerHTML = doc.body.innerHTML;
|
|
189
|
+
} else {
|
|
190
|
+
el.outerHTML = data.updates[id];
|
|
191
|
+
}
|
|
192
|
+
}
|
|
162
193
|
}
|
|
163
194
|
|
|
164
195
|
// Ensure overlays are still in the DOM (in case the body was replaced)
|
|
@@ -349,4 +380,5 @@ function htag_event(id, event_name, event) {
|
|
|
349
380
|
window.htag_transport(payload);
|
|
350
381
|
});
|
|
351
382
|
}
|
|
352
|
-
"""
|
|
383
|
+
""")
|
|
384
|
+
|
|
@@ -438,7 +438,10 @@ class GTag: # aka "Generic Tag"
|
|
|
438
438
|
else:
|
|
439
439
|
self.tag = "div" # fallback
|
|
440
440
|
|
|
441
|
-
self
|
|
441
|
+
if getattr(self, "tag", None) in ("style", "script"):
|
|
442
|
+
self.id = ""
|
|
443
|
+
else:
|
|
444
|
+
self.id = f"{self.tag}-{id(self)}"
|
|
442
445
|
logger.debug("Created Tag: %s (id: %s)", self.tag, self.id)
|
|
443
446
|
|
|
444
447
|
# Scoped style: auto-prefix CSS rules with a unique class per component class
|
|
@@ -339,16 +339,30 @@ class AppRunner(BaseApp):
|
|
|
339
339
|
|
|
340
340
|
def collect_statics(self, tag: GTag, result: list[str]) -> None:
|
|
341
341
|
"""Recursively collects statics from the whole tag tree."""
|
|
342
|
+
seen_ids = set()
|
|
343
|
+
seen_contents = set(result)
|
|
342
344
|
|
|
343
345
|
def visitor(t: GTag) -> None:
|
|
344
346
|
s_instance = getattr(t, "statics", [])
|
|
345
347
|
s_class = getattr(t.__class__, "statics", [])
|
|
346
|
-
|
|
348
|
+
|
|
349
|
+
# Avoid processing the exact same list twice (common case)
|
|
350
|
+
lists = [s_class]
|
|
351
|
+
if s_instance is not s_class:
|
|
352
|
+
lists.append(s_instance)
|
|
353
|
+
|
|
354
|
+
for s_list in lists:
|
|
347
355
|
if not isinstance(s_list, (list, tuple)):
|
|
348
356
|
s_list = [s_list]
|
|
349
357
|
for s in s_list:
|
|
358
|
+
sid = id(s)
|
|
359
|
+
if sid in seen_ids:
|
|
360
|
+
continue
|
|
361
|
+
seen_ids.add(sid)
|
|
362
|
+
|
|
350
363
|
s_str = str(s)
|
|
351
|
-
if s_str not in
|
|
364
|
+
if s_str not in seen_contents:
|
|
365
|
+
seen_contents.add(s_str)
|
|
352
366
|
result.append(s_str)
|
|
353
367
|
|
|
354
368
|
self._walk_tree(tag, visitor)
|
|
@@ -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.7"
|
|
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"
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
from htag import Tag, App
|
|
2
|
+
|
|
3
|
+
def test_statics_deduplication_same_object():
|
|
4
|
+
# A shared static object
|
|
5
|
+
SHARED_JS = Tag.script("console.log('shared')")
|
|
6
|
+
|
|
7
|
+
class Comp1(Tag.div):
|
|
8
|
+
statics = [SHARED_JS]
|
|
9
|
+
class Comp2(Tag.div):
|
|
10
|
+
statics = [SHARED_JS]
|
|
11
|
+
|
|
12
|
+
class MyApp(App):
|
|
13
|
+
def init(self):
|
|
14
|
+
self += Comp1()
|
|
15
|
+
self += Comp2()
|
|
16
|
+
|
|
17
|
+
app = MyApp()
|
|
18
|
+
html = app._render_page()
|
|
19
|
+
|
|
20
|
+
# Count occurrences of SHARED_JS in html
|
|
21
|
+
count = html.count("console.log('shared')")
|
|
22
|
+
assert count == 1, "Deduplication failed for same object"
|
|
23
|
+
|
|
24
|
+
def test_statics_deduplication_identical_content():
|
|
25
|
+
# Identical but different objects
|
|
26
|
+
JS1 = Tag.script("console.log('identical')")
|
|
27
|
+
JS2 = Tag.script("console.log('identical')")
|
|
28
|
+
|
|
29
|
+
class Comp3(Tag.div):
|
|
30
|
+
statics = [JS1]
|
|
31
|
+
class Comp4(Tag.div):
|
|
32
|
+
statics = [JS2]
|
|
33
|
+
|
|
34
|
+
app = App(Comp3(), Comp4())
|
|
35
|
+
html = app._render_page()
|
|
36
|
+
count = html.count("console.log('identical')")
|
|
37
|
+
assert count == 1, "Deduplication failed for identical content"
|
|
38
|
+
|
|
39
|
+
def test_statics_inheritance_optimization():
|
|
40
|
+
SHARED_STYLE = Tag.style("body { color: red; }")
|
|
41
|
+
|
|
42
|
+
class Comp(Tag.div):
|
|
43
|
+
statics = [SHARED_STYLE]
|
|
44
|
+
|
|
45
|
+
c = Comp()
|
|
46
|
+
# By default, c.statics is the same object as Comp.statics
|
|
47
|
+
assert c.statics is Comp.statics
|
|
48
|
+
|
|
49
|
+
app = App(c)
|
|
50
|
+
html = app._render_page()
|
|
51
|
+
count = html.count("body { color: red; }")
|
|
52
|
+
assert count == 1, "Deduplication failed for class/instance shared statics"
|
|
53
|
+
|
|
54
|
+
def test_script_style_no_auto_id():
|
|
55
|
+
# Verify that script and style tags don't have auto-generated IDs
|
|
56
|
+
# which used to break content deduplication.
|
|
57
|
+
s = Tag.script("alert(1)")
|
|
58
|
+
st = Tag.style(".foo {}")
|
|
59
|
+
d = Tag.div("hello")
|
|
60
|
+
|
|
61
|
+
assert s.id == ""
|
|
62
|
+
assert st.id == ""
|
|
63
|
+
assert d.id.startswith("div-")
|
|
64
|
+
|
|
65
|
+
assert 'id=""' in str(s) or 'id' not in str(s)
|
|
66
|
+
assert 'id=""' in str(st) or 'id' not in str(st)
|
|
67
|
+
assert f'id="{d.id}"' in str(d)
|
|
68
|
+
|
|
69
|
+
def test_statics_collection_with_mixed_types():
|
|
70
|
+
# Test that collect_statics handles non-list statics gracefully
|
|
71
|
+
class Comp(Tag.div):
|
|
72
|
+
statics = Tag.script("console.log('single')")
|
|
73
|
+
|
|
74
|
+
app = App(Comp())
|
|
75
|
+
html = app._render_page()
|
|
76
|
+
assert "console.log('single')" in html
|
|
77
|
+
|
|
78
|
+
def test_statics_deduplication_across_renders():
|
|
79
|
+
# Verify that App.sent_statics works and prevents re-sending same statics
|
|
80
|
+
SHARED = Tag.script("console.log('shared')")
|
|
81
|
+
|
|
82
|
+
class MyApp(App):
|
|
83
|
+
statics = [SHARED]
|
|
84
|
+
def init(self):
|
|
85
|
+
self.main = Tag.div()
|
|
86
|
+
self += self.main
|
|
87
|
+
|
|
88
|
+
app = MyApp()
|
|
89
|
+
# Initial render: collects SHARED and puts it in sent_statics
|
|
90
|
+
app._render_page()
|
|
91
|
+
assert SHARED in app.sent_statics or str(SHARED) in app.sent_statics
|
|
92
|
+
|
|
93
|
+
# Simulate a dynamic update adding a component with the same static (different instance/list)
|
|
94
|
+
class Comp(Tag.div):
|
|
95
|
+
statics = [SHARED]
|
|
96
|
+
|
|
97
|
+
app.main.add(Comp())
|
|
98
|
+
|
|
99
|
+
# broadcast_updates logic (manual verification)
|
|
100
|
+
all_statics = []
|
|
101
|
+
app.collect_statics(app, all_statics) # Should collect SHARED again
|
|
102
|
+
|
|
103
|
+
# But new_statics calculation should skip it if already in sent_statics
|
|
104
|
+
new_statics = [s for s in all_statics if s not in app.sent_statics]
|
|
105
|
+
|
|
106
|
+
assert "console.log('shared')" not in "".join(new_statics)
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|