webwidgets 0.2.0__tar.gz → 0.2.1__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.
- {webwidgets-0.2.0 → webwidgets-0.2.1}/PKG-INFO +1 -1
- {webwidgets-0.2.0 → webwidgets-0.2.1}/tests/compilation/test_css.py +144 -69
- webwidgets-0.2.1/tests/utility/test_representation.py +118 -0
- {webwidgets-0.2.0 → webwidgets-0.2.1}/tests/utility/test_validation.py +65 -1
- {webwidgets-0.2.0 → webwidgets-0.2.1}/webwidgets/__init__.py +1 -1
- {webwidgets-0.2.0 → webwidgets-0.2.1}/webwidgets/compilation/css/__init__.py +2 -1
- {webwidgets-0.2.0 → webwidgets-0.2.1}/webwidgets/compilation/css/css.py +86 -28
- {webwidgets-0.2.0 → webwidgets-0.2.1}/webwidgets/compilation/html/html_node.py +3 -1
- {webwidgets-0.2.0 → webwidgets-0.2.1}/webwidgets/utility/__init__.py +1 -0
- webwidgets-0.2.1/webwidgets/utility/representation.py +34 -0
- {webwidgets-0.2.0 → webwidgets-0.2.1}/.github/workflows/cd.yml +0 -0
- {webwidgets-0.2.0 → webwidgets-0.2.1}/.github/workflows/ci-full.yml +0 -0
- {webwidgets-0.2.0 → webwidgets-0.2.1}/.github/workflows/ci-quick.yml +0 -0
- {webwidgets-0.2.0 → webwidgets-0.2.1}/.gitignore +0 -0
- {webwidgets-0.2.0 → webwidgets-0.2.1}/LICENSE +0 -0
- {webwidgets-0.2.0 → webwidgets-0.2.1}/README.md +0 -0
- {webwidgets-0.2.0 → webwidgets-0.2.1}/pyproject.toml +0 -0
- {webwidgets-0.2.0 → webwidgets-0.2.1}/tests/__init__.py +0 -0
- {webwidgets-0.2.0 → webwidgets-0.2.1}/tests/compilation/__init__.py +0 -0
- {webwidgets-0.2.0 → webwidgets-0.2.1}/tests/compilation/test_html_node.py +0 -0
- {webwidgets-0.2.0 → webwidgets-0.2.1}/tests/compilation/test_html_tags.py +0 -0
- {webwidgets-0.2.0 → webwidgets-0.2.1}/tests/utility/__init__.py +0 -0
- {webwidgets-0.2.0 → webwidgets-0.2.1}/tests/utility/test_sanitizing.py +0 -0
- {webwidgets-0.2.0 → webwidgets-0.2.1}/webwidgets/compilation/__init__.py +0 -0
- {webwidgets-0.2.0 → webwidgets-0.2.1}/webwidgets/compilation/html/__init__.py +0 -0
- {webwidgets-0.2.0 → webwidgets-0.2.1}/webwidgets/compilation/html/html_tags.py +0 -0
- {webwidgets-0.2.0 → webwidgets-0.2.1}/webwidgets/utility/sanitizing.py +0 -0
- {webwidgets-0.2.0 → webwidgets-0.2.1}/webwidgets/utility/validation.py +0 -0
@@ -11,12 +11,38 @@
|
|
11
11
|
# =======================================================================
|
12
12
|
|
13
13
|
import pytest
|
14
|
+
from typing import Any, Dict, List
|
14
15
|
from webwidgets.compilation.html.html_node import HTMLNode
|
15
16
|
from webwidgets.compilation.html.html_tags import TextNode
|
16
|
-
from webwidgets.compilation.css.css import compile_css, CompiledCSS,
|
17
|
+
from webwidgets.compilation.css.css import compile_css, CSSRule, CompiledCSS, \
|
18
|
+
apply_css, default_rule_namer
|
17
19
|
|
18
20
|
|
19
21
|
class TestCompileCSS:
|
22
|
+
@staticmethod
|
23
|
+
def _serialize_rules(rules: List[CSSRule]) -> List[Dict[str, Any]]:
|
24
|
+
"""Utility function to convert a list of :py:class:`CSSRule` objects
|
25
|
+
into a dictionary that can be used in testing.
|
26
|
+
|
27
|
+
:param rules: List of :py:class:`CSSRule` objects.
|
28
|
+
:type rules: List[CSSRule]
|
29
|
+
:return: List of the member variables of each :py:class:`CSSRule`.
|
30
|
+
:rtype: Dict[int, Any]
|
31
|
+
"""
|
32
|
+
return [vars(rule) for rule in rules]
|
33
|
+
|
34
|
+
@staticmethod
|
35
|
+
def _serialize_mapping(mapping: Dict[int, List[CSSRule]]) -> Dict[int, List[str]]:
|
36
|
+
"""Utility function to convert a :py:attr:`CompiledCSS.mapping` object
|
37
|
+
into a dictionary that can be used in testing.
|
38
|
+
|
39
|
+
:param mapping: :py:attr:`CompiledCSS.mapping` object.
|
40
|
+
:type mapping: Dict[int, List[CSSRule]]
|
41
|
+
:return: Dictionary mapping each node ID to the name of the rules that
|
42
|
+
achieve the same style.
|
43
|
+
"""
|
44
|
+
return {i: [r.name for r in rules] for i, rules in mapping.items()}
|
45
|
+
|
20
46
|
def test_argument_type(self):
|
21
47
|
"""Compares compilation when given a node object versus a list of
|
22
48
|
nodes.
|
@@ -30,10 +56,10 @@ class TestCompileCSS:
|
|
30
56
|
)
|
31
57
|
|
32
58
|
# Define expected compilation results
|
33
|
-
expected_rules =
|
34
|
-
|
35
|
-
|
36
|
-
|
59
|
+
expected_rules = [
|
60
|
+
{"name": "r0", "declarations": {"a": "5"}},
|
61
|
+
{"name": "r1", "declarations": {"b": "4"}}
|
62
|
+
]
|
37
63
|
expected_mapping = {
|
38
64
|
id(tree): ['r0', 'r1'],
|
39
65
|
id(tree.children[0]): ['r0']
|
@@ -45,8 +71,10 @@ class TestCompileCSS:
|
|
45
71
|
# Check results of compilation
|
46
72
|
assert compiled_css.trees == [tree]
|
47
73
|
assert [id(t) for t in compiled_css.trees] == [id(tree)]
|
48
|
-
assert
|
49
|
-
|
74
|
+
assert TestCompileCSS._serialize_rules(
|
75
|
+
compiled_css.rules) == expected_rules
|
76
|
+
assert TestCompileCSS._serialize_mapping(
|
77
|
+
compiled_css.mapping) == expected_mapping
|
50
78
|
|
51
79
|
# Compile tree as list of one node
|
52
80
|
compiled_css2 = compile_css([tree])
|
@@ -54,8 +82,10 @@ class TestCompileCSS:
|
|
54
82
|
# Check results of compilation again (should be unchanged)
|
55
83
|
assert compiled_css2.trees == [tree]
|
56
84
|
assert [id(t) for t in compiled_css2.trees] == [id(tree)]
|
57
|
-
assert
|
58
|
-
|
85
|
+
assert TestCompileCSS._serialize_rules(
|
86
|
+
compiled_css2.rules) == expected_rules
|
87
|
+
assert TestCompileCSS._serialize_mapping(
|
88
|
+
compiled_css2.mapping) == expected_mapping
|
59
89
|
|
60
90
|
def test_basic_compilation(self):
|
61
91
|
# Create some HTML nodes with different styles
|
@@ -72,17 +102,19 @@ class TestCompileCSS:
|
|
72
102
|
id(node1), id(node2), id(node3)]
|
73
103
|
|
74
104
|
# Check that the rules are correctly generated
|
75
|
-
expected_rules =
|
76
|
-
|
77
|
-
|
78
|
-
|
79
|
-
|
80
|
-
assert
|
105
|
+
expected_rules = [
|
106
|
+
{"name": "r0", "declarations": {"color": "blue"}},
|
107
|
+
{"name": "r1", "declarations": {"margin": "0"}},
|
108
|
+
{"name": "r2", "declarations": {"padding": "0"}}
|
109
|
+
]
|
110
|
+
assert TestCompileCSS._serialize_rules(
|
111
|
+
compiled_css.rules) == expected_rules
|
81
112
|
|
82
113
|
# Check that the mapping is correctly generated
|
83
114
|
expected_mapping = {id(node1): ['r1', 'r2'], id(
|
84
115
|
node2): ['r0', 'r1'], id(node3): ['r1', 'r2']}
|
85
|
-
assert
|
116
|
+
assert TestCompileCSS._serialize_mapping(
|
117
|
+
compiled_css.mapping) == expected_mapping
|
86
118
|
|
87
119
|
def test_nested_compilation_one_tree(self):
|
88
120
|
# Create some nested HTML nodes
|
@@ -104,13 +136,14 @@ class TestCompileCSS:
|
|
104
136
|
assert [id(t) for t in compiled_css.trees] == [id(tree)]
|
105
137
|
|
106
138
|
# Check that the rules are correctly generated
|
107
|
-
expected_rules =
|
108
|
-
|
109
|
-
|
110
|
-
|
111
|
-
|
112
|
-
|
113
|
-
assert
|
139
|
+
expected_rules = [
|
140
|
+
{"name": "r0", "declarations": {"color": "blue"}},
|
141
|
+
{"name": "r1", "declarations": {"margin": "0"}},
|
142
|
+
{"name": "r2", "declarations": {"margin": "5"}},
|
143
|
+
{"name": "r3", "declarations": {"padding": "0"}}
|
144
|
+
]
|
145
|
+
assert TestCompileCSS._serialize_rules(
|
146
|
+
compiled_css.rules) == expected_rules
|
114
147
|
|
115
148
|
# Check that the mapping is correctly generated
|
116
149
|
expected_mapping = {
|
@@ -120,7 +153,8 @@ class TestCompileCSS:
|
|
120
153
|
id(tree.children[0].children[0]): [],
|
121
154
|
id(tree.children[1].children[0]): []
|
122
155
|
}
|
123
|
-
assert
|
156
|
+
assert TestCompileCSS._serialize_mapping(
|
157
|
+
compiled_css.mapping) == expected_mapping
|
124
158
|
|
125
159
|
def test_nested_compilation_two_trees(self):
|
126
160
|
# Create 2 trees
|
@@ -146,13 +180,14 @@ class TestCompileCSS:
|
|
146
180
|
id(tree1), id(tree2)]
|
147
181
|
|
148
182
|
# Check that the rules are correctly generated
|
149
|
-
expected_rules =
|
150
|
-
|
151
|
-
|
152
|
-
|
153
|
-
|
154
|
-
|
155
|
-
assert
|
183
|
+
expected_rules = [
|
184
|
+
{"name": "r0", "declarations": {"color": "red"}},
|
185
|
+
{"name": "r1", "declarations": {"margin": "10"}},
|
186
|
+
{"name": "r2", "declarations": {"margin": "5"}},
|
187
|
+
{"name": "r3", "declarations": {"padding": "0"}}
|
188
|
+
]
|
189
|
+
assert TestCompileCSS._serialize_rules(
|
190
|
+
compiled_css.rules) == expected_rules
|
156
191
|
|
157
192
|
# Check that the mapping is correctly generated
|
158
193
|
expected_mapping = {
|
@@ -161,7 +196,8 @@ class TestCompileCSS:
|
|
161
196
|
id(tree2): ['r2', 'r3'],
|
162
197
|
id(tree2.children[0]): ['r1']
|
163
198
|
}
|
164
|
-
assert
|
199
|
+
assert TestCompileCSS._serialize_mapping(
|
200
|
+
compiled_css.mapping) == expected_mapping
|
165
201
|
|
166
202
|
def test_rules_numbered_in_order(self):
|
167
203
|
"""Test that rules are numbered in lexicographical order"""
|
@@ -174,14 +210,15 @@ class TestCompileCSS:
|
|
174
210
|
]
|
175
211
|
)
|
176
212
|
compiled_css = compile_css(tree)
|
177
|
-
expected_rules =
|
178
|
-
|
179
|
-
|
180
|
-
|
181
|
-
|
182
|
-
|
183
|
-
|
184
|
-
assert
|
213
|
+
expected_rules = [
|
214
|
+
{"name": "r0", "declarations": {"a": "10"}},
|
215
|
+
{"name": "r1", "declarations": {"a": "5"}},
|
216
|
+
{"name": "r2", "declarations": {"b": "10"}},
|
217
|
+
{"name": "r3", "declarations": {"b": "4"}},
|
218
|
+
{"name": "r4", "declarations": {"c": "5"}},
|
219
|
+
]
|
220
|
+
assert TestCompileCSS._serialize_rules(
|
221
|
+
compiled_css.rules) == expected_rules
|
185
222
|
|
186
223
|
def test_duplicate_node(self):
|
187
224
|
"""Test that adding the same node twice does not impact compilation"""
|
@@ -193,11 +230,11 @@ class TestCompileCSS:
|
|
193
230
|
HTMLNode(style={"b": "10"}),
|
194
231
|
]
|
195
232
|
)
|
196
|
-
expected_rules =
|
197
|
-
|
198
|
-
|
199
|
-
|
200
|
-
|
233
|
+
expected_rules = [
|
234
|
+
{"name": "r0", "declarations": {"a": "5"}},
|
235
|
+
{"name": "r1", "declarations": {"b": "10"}},
|
236
|
+
{"name": "r2", "declarations": {"b": "4"}}
|
237
|
+
]
|
201
238
|
expected_mapping = {
|
202
239
|
id(tree): ['r0', 'r2'],
|
203
240
|
id(tree.children[0]): ['r0'],
|
@@ -206,8 +243,10 @@ class TestCompileCSS:
|
|
206
243
|
compiled_css = compile_css([tree])
|
207
244
|
assert compiled_css.trees == [tree]
|
208
245
|
assert [id(t) for t in compiled_css.trees] == [id(tree)]
|
209
|
-
assert
|
210
|
-
|
246
|
+
assert TestCompileCSS._serialize_rules(
|
247
|
+
compiled_css.rules) == expected_rules
|
248
|
+
assert TestCompileCSS._serialize_mapping(
|
249
|
+
compiled_css.mapping) == expected_mapping
|
211
250
|
|
212
251
|
# Compiling the tree and one of its children, which should already be
|
213
252
|
# included recursively from the tree itself and should not affect the
|
@@ -216,25 +255,41 @@ class TestCompileCSS:
|
|
216
255
|
assert compiled_css2.trees == [tree, tree.children[0]]
|
217
256
|
assert [id(t) for t in compiled_css2.trees] == [
|
218
257
|
id(tree), id(tree.children[0])]
|
219
|
-
assert
|
220
|
-
|
258
|
+
assert TestCompileCSS._serialize_rules(
|
259
|
+
compiled_css2.rules) == expected_rules
|
260
|
+
assert TestCompileCSS._serialize_mapping(
|
261
|
+
compiled_css2.mapping) == expected_mapping
|
262
|
+
|
263
|
+
@pytest.mark.parametrize("rule_namer, names", [
|
264
|
+
(lambda _, i: f"rule{i}", ["rule0", "rule1", "rule2"]),
|
265
|
+
(lambda _, i: f"rule-{i + 1}", ["rule-1", "rule-2", "rule-3"]),
|
266
|
+
(lambda r, i: f"{list(r[i].declarations.items())[0][0]}{i}", [
|
267
|
+
"az0", "bz1", "bz2"]),
|
268
|
+
(lambda r, i: f"{list(r[i].declarations.items())[0][0][0]}{i}", [
|
269
|
+
"a0", "b1", "b2"]),
|
270
|
+
(lambda r, i: f"r{list(r[i].declarations.items())[0][1]}-{i}", [
|
271
|
+
"r10-1", "r4-2", "r5-0"]),
|
272
|
+
])
|
273
|
+
def test_custom_rule_names(self, rule_namer, names):
|
274
|
+
tree = HTMLNode(
|
275
|
+
style={"az": "5", "bz": "4"},
|
276
|
+
children=[
|
277
|
+
HTMLNode(style={"az": "5"}),
|
278
|
+
HTMLNode(style={"bz": "10"}),
|
279
|
+
]
|
280
|
+
)
|
281
|
+
compiled_css = compile_css(tree, rule_namer=rule_namer)
|
282
|
+
assert [r.name for r in compiled_css.rules] == names
|
221
283
|
|
222
284
|
|
223
285
|
class TestCompiledCSS:
|
224
286
|
def test_export_custom_compiled_css(self):
|
225
|
-
rules =
|
226
|
-
"r0":
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
"color": "blue"
|
232
|
-
},
|
233
|
-
"r2": {
|
234
|
-
"background-color": "white",
|
235
|
-
"font-size": "16px"
|
236
|
-
}
|
237
|
-
}
|
287
|
+
rules = [
|
288
|
+
CSSRule(name="r0", declarations={"margin": "0", "padding": "0"}),
|
289
|
+
CSSRule(name="r1", declarations={"color": "blue"}),
|
290
|
+
CSSRule(name="r2", declarations={
|
291
|
+
"background-color": "white", "font-size": "16px"})
|
292
|
+
]
|
238
293
|
compiled_css = CompiledCSS(trees=None,
|
239
294
|
rules=rules,
|
240
295
|
mapping=None)
|
@@ -364,12 +419,12 @@ class TestApplyCSS:
|
|
364
419
|
|
365
420
|
# Compiling and applying CSS to the tree
|
366
421
|
compiled_css = compile_css(tree)
|
367
|
-
assert compiled_css.rules ==
|
368
|
-
"r0": {"color": "blue"},
|
369
|
-
"r1": {"color": "green"},
|
370
|
-
"r2": {"margin": "0"},
|
371
|
-
"r3": {"padding": "0"}
|
372
|
-
|
422
|
+
assert TestCompileCSS._serialize_rules(compiled_css.rules) == [
|
423
|
+
{"name": "r0", "declarations": {"color": "blue"}},
|
424
|
+
{"name": "r1", "declarations": {"color": "green"}},
|
425
|
+
{"name": "r2", "declarations": {"margin": "0"}},
|
426
|
+
{"name": "r3", "declarations": {"padding": "0"}}
|
427
|
+
]
|
373
428
|
apply_css(compiled_css, tree)
|
374
429
|
|
375
430
|
# Checking the tree's new classes
|
@@ -398,7 +453,7 @@ class TestApplyCSS:
|
|
398
453
|
)
|
399
454
|
html_before = tree.to_html()
|
400
455
|
compiled_css = compile_css(tree)
|
401
|
-
assert compiled_css.rules ==
|
456
|
+
assert compiled_css.rules == []
|
402
457
|
apply_css(compiled_css, tree)
|
403
458
|
html_after = tree.to_html()
|
404
459
|
|
@@ -475,3 +530,23 @@ class TestApplyCSS:
|
|
475
530
|
apply_css(compiled_css, tree)
|
476
531
|
assert "class" not in tree.attributes
|
477
532
|
assert tree.to_html() == '<htmlnode></htmlnode>'
|
533
|
+
|
534
|
+
|
535
|
+
class TestDefaultRuleNamer:
|
536
|
+
def test_default_rule_namer(self):
|
537
|
+
rules = [CSSRule(None, {"color": "red"}),
|
538
|
+
CSSRule(None, {"margin": "0"})]
|
539
|
+
for i, rule in enumerate(rules):
|
540
|
+
rule.name = default_rule_namer(rules=rules, index=i)
|
541
|
+
assert rules[0].name == "r0"
|
542
|
+
assert rules[1].name == "r1"
|
543
|
+
|
544
|
+
def test_default_rule_namer_override(self):
|
545
|
+
rules = [CSSRule("first", {"color": "red"}),
|
546
|
+
CSSRule("second", {"margin": "0"})]
|
547
|
+
assert rules[0].name == "first"
|
548
|
+
assert rules[1].name == "second"
|
549
|
+
for i, rule in enumerate(rules):
|
550
|
+
rule.name = default_rule_namer(rules=rules, index=i)
|
551
|
+
assert rules[0].name == "r0"
|
552
|
+
assert rules[1].name == "r1"
|
@@ -0,0 +1,118 @@
|
|
1
|
+
# =======================================================================
|
2
|
+
#
|
3
|
+
# This file is part of WebWidgets, a Python package for designing web
|
4
|
+
# UIs.
|
5
|
+
#
|
6
|
+
# You should have received a copy of the MIT License along with
|
7
|
+
# WebWidgets. If not, see <https://opensource.org/license/mit>.
|
8
|
+
#
|
9
|
+
# Copyright(C) 2025, mlaasri
|
10
|
+
#
|
11
|
+
# =======================================================================
|
12
|
+
|
13
|
+
from webwidgets.utility.representation import ReprMixin
|
14
|
+
|
15
|
+
|
16
|
+
class TestRepresentation:
|
17
|
+
def test_repr_without_attributes(self):
|
18
|
+
"""Test case without any attributes"""
|
19
|
+
class EmptyClass(ReprMixin):
|
20
|
+
pass
|
21
|
+
empty_obj = EmptyClass()
|
22
|
+
assert str(empty_obj) == "EmptyClass()"
|
23
|
+
|
24
|
+
def test_repr_with_none_value(self):
|
25
|
+
"""Test case with None value for an attribute"""
|
26
|
+
class MyClass(ReprMixin):
|
27
|
+
def __init__(self, a, b=None):
|
28
|
+
self.a = a
|
29
|
+
self.b = b
|
30
|
+
obj = MyClass(1)
|
31
|
+
assert str(obj) == "MyClass(a=1, b=None)"
|
32
|
+
|
33
|
+
def test_repr_with_multiple_attributes(self):
|
34
|
+
"""Test case with multiple attributes"""
|
35
|
+
class ComplexClass(ReprMixin):
|
36
|
+
def __init__(self, a, b, c=None, d=None):
|
37
|
+
self.a = a
|
38
|
+
self.b = b
|
39
|
+
self.c = c
|
40
|
+
self.d = d
|
41
|
+
obj = ComplexClass(1, 2, c=3, d=4)
|
42
|
+
assert str(obj) == "ComplexClass(a=1, b=2, c=3, d=4)"
|
43
|
+
|
44
|
+
def test_repr_with_multiple_types(self):
|
45
|
+
"""Test case with multiple types of attributes"""
|
46
|
+
class MixedTypeClass(ReprMixin):
|
47
|
+
def __init__(self, a: int, b: float, c: str):
|
48
|
+
self.a = a
|
49
|
+
self.b = b
|
50
|
+
self.c = c
|
51
|
+
obj = MixedTypeClass(1, 2.5, "test")
|
52
|
+
assert str(obj) == "MixedTypeClass(a=1, b=2.5, c='test')"
|
53
|
+
|
54
|
+
def test_repr_with_large_number_of_attributes(self):
|
55
|
+
"""Test case with a large number of attributes"""
|
56
|
+
class LargeObject(ReprMixin):
|
57
|
+
def __init__(self, **kwargs):
|
58
|
+
for k, v in kwargs.items():
|
59
|
+
setattr(self, k, v)
|
60
|
+
obj = LargeObject(a=1, b=2, c=3, d=4, e=5, f=6, g=7, h=8, i=9, j=10)
|
61
|
+
assert str(
|
62
|
+
obj) == "LargeObject(a=1, b=2, c=3, d=4, e=5, f=6, g=7, h=8, i=9, j=10)"
|
63
|
+
|
64
|
+
def test_repr_with_nested_objects(self):
|
65
|
+
"""Test case with nested object as attribute"""
|
66
|
+
class Inner(ReprMixin):
|
67
|
+
def __init__(self, a, b):
|
68
|
+
self.a = a
|
69
|
+
self.b = b
|
70
|
+
|
71
|
+
class Outer(ReprMixin):
|
72
|
+
def __init__(self, obj=None):
|
73
|
+
self.obj = obj
|
74
|
+
complex_obj = Outer(obj=Inner(a=1, b=2))
|
75
|
+
assert str(complex_obj) == "Outer(obj=Inner(a=1, b=2))"
|
76
|
+
|
77
|
+
def test_repr_with_nested_list(self):
|
78
|
+
"""Test case with list of objects as attribute"""
|
79
|
+
class Inner(ReprMixin):
|
80
|
+
def __init__(self, a):
|
81
|
+
self.a = a
|
82
|
+
|
83
|
+
class Outer(ReprMixin):
|
84
|
+
def __init__(self):
|
85
|
+
self.obj = [Inner(1), Inner(2)]
|
86
|
+
obj = Outer()
|
87
|
+
assert str(obj) == "Outer(obj=[Inner(a=1), Inner(a=2)])"
|
88
|
+
|
89
|
+
def test_repr_with_nested_dict(self):
|
90
|
+
"""Test case with list of objects as attribute"""
|
91
|
+
class Inner(ReprMixin):
|
92
|
+
def __init__(self, a):
|
93
|
+
self.a = a
|
94
|
+
|
95
|
+
class Outer(ReprMixin):
|
96
|
+
def __init__(self):
|
97
|
+
self.d = {
|
98
|
+
"1": Inner(1),
|
99
|
+
"2": Inner(2)
|
100
|
+
}
|
101
|
+
obj = Outer()
|
102
|
+
assert str(obj) == "Outer(d={'1': Inner(a=1), '2': Inner(a=2)})"
|
103
|
+
|
104
|
+
def test_repr_with_nested_dict_of_list(self):
|
105
|
+
"""Test case with dict containing list of objects as attribute"""
|
106
|
+
class Inner(ReprMixin):
|
107
|
+
def __init__(self, a):
|
108
|
+
self.a = a
|
109
|
+
|
110
|
+
class Outer(ReprMixin):
|
111
|
+
def __init__(self):
|
112
|
+
self.d = {
|
113
|
+
"odd": [Inner(1), Inner(3)],
|
114
|
+
"even": [Inner(2)]
|
115
|
+
}
|
116
|
+
obj = Outer()
|
117
|
+
assert str(obj) == "Outer(d={'odd': [Inner(a=1), " \
|
118
|
+
"Inner(a=3)], 'even': [Inner(a=2)]})"
|
@@ -78,6 +78,23 @@ class TestValidate:
|
|
78
78
|
with pytest.raises(ValueError, match=re.escape(', '.join(chars))):
|
79
79
|
validate_css_identifier(f"my-class-{chars}")
|
80
80
|
|
81
|
+
@pytest.mark.parametrize("code, chars, raise_on_start", [
|
82
|
+
# Injection in rule name
|
83
|
+
("rule{}custom-code", "{, }", False),
|
84
|
+
("rule {}custom-code", " , {, }", False),
|
85
|
+
|
86
|
+
# Injection in property name
|
87
|
+
("}custom-code", None, True),
|
88
|
+
("} custom-code", None, True),
|
89
|
+
("url(\"somewhere.com\")", "(, \", ., \", )", False),
|
90
|
+
])
|
91
|
+
def test_code_injection_in_css_identifier(self, code, chars, raise_on_start):
|
92
|
+
"""Test that code injected into CSS identifier raises an exception"""
|
93
|
+
match = (r"must start with.*:.*" + code) if raise_on_start else \
|
94
|
+
(r"Invalid character\(s\).*" + re.escape(chars))
|
95
|
+
with pytest.raises(ValueError, match=match):
|
96
|
+
validate_css_identifier(code)
|
97
|
+
|
81
98
|
def test_valid_html_classes(self):
|
82
99
|
"""Test that valid HTML class attributes are accepted"""
|
83
100
|
validate_html_class("")
|
@@ -108,6 +125,23 @@ class TestValidate:
|
|
108
125
|
with pytest.raises(ValueError, match="must start with"):
|
109
126
|
validate_html_class("my-class123 -er4 my-other-class")
|
110
127
|
|
128
|
+
@pytest.mark.parametrize("code, chars, raise_on_start", [
|
129
|
+
# Exception are raised on first offending class before space
|
130
|
+
(">custom-code", None, True),
|
131
|
+
("\">custom-code", None, True),
|
132
|
+
("c>custom-code", ">", False),
|
133
|
+
("c\">custom-code", "\", >", False),
|
134
|
+
("c\"> custom-code", "\", >", False),
|
135
|
+
("c\">custom-code<div class=\"", "\", >, <", False),
|
136
|
+
("c\"><script src=\"file.js\"></script>", "\", >, <", False),
|
137
|
+
])
|
138
|
+
def test_code_injection_in_html_class(self, code, chars, raise_on_start):
|
139
|
+
"""Test that HTML code injected into class attribute raises an exception"""
|
140
|
+
match = (r"must start with.*:.*" + code) if raise_on_start else \
|
141
|
+
(r"Invalid character\(s\).*" + re.escape(chars))
|
142
|
+
with pytest.raises(ValueError, match=match):
|
143
|
+
validate_html_class(code)
|
144
|
+
|
111
145
|
@pytest.mark.parametrize("class_in, valid", [
|
112
146
|
(None, True),
|
113
147
|
("", True),
|
@@ -123,7 +157,7 @@ class TestValidate:
|
|
123
157
|
])
|
124
158
|
@pytest.mark.parametrize("add_r2_in", [False, True])
|
125
159
|
def test_validation_within_apply_css(self, class_in, valid, add_r2_in):
|
126
|
-
"""Tests that valid class attributes make it through rendering"""
|
160
|
+
"""Tests that valid class attributes make it through HTML rendering"""
|
127
161
|
# Compiling and applying CSS to a tree
|
128
162
|
c_in = None if class_in is None else ' '.join(
|
129
163
|
([class_in] if class_in else []) + (["r2"] if add_r2_in else []))
|
@@ -152,3 +186,33 @@ class TestValidate:
|
|
152
186
|
tree.validate_attributes()
|
153
187
|
with pytest.raises(ValueError):
|
154
188
|
tree.to_html()
|
189
|
+
|
190
|
+
@pytest.mark.parametrize("rule_namer, valid", [
|
191
|
+
(None, True), # Default rule namer
|
192
|
+
(lambda _, i: f"rule{i}", True),
|
193
|
+
(lambda _, i: f"r-{i + 1}", True),
|
194
|
+
(lambda _, i: f"--r-{i + 1}", True),
|
195
|
+
(lambda r, i: f"{list(r[i].declarations.items())[0][0][0]}{i}", True),
|
196
|
+
(lambda _, i: str(i), False), # Starts with digit
|
197
|
+
(lambda _, i: f"-r{i}", False), # Starts with single hyphen
|
198
|
+
(lambda _, i: f"rule {i + 1}", False), # Invalid character (space)
|
199
|
+
(lambda _, i: f"r={i}", False), # Invalid character (=)
|
200
|
+
(lambda r, i: f"{list(r[i].declarations.items())[0]}",
|
201
|
+
False), # Invalid characters (comma...)
|
202
|
+
(lambda _, i: f"r{i}" + "{}custom-code", False), # Code injection
|
203
|
+
])
|
204
|
+
def test_validation_within_to_css(self, rule_namer, valid):
|
205
|
+
"""Tests that valid class attributes make it through CSS rendering"""
|
206
|
+
tree = HTMLNode(
|
207
|
+
style={"az": "5", "bz": "4"},
|
208
|
+
children=[
|
209
|
+
HTMLNode(style={"az": "5"}),
|
210
|
+
HTMLNode(style={"bz": "10"}),
|
211
|
+
]
|
212
|
+
)
|
213
|
+
compiled_css = compile_css(tree, rule_namer=rule_namer)
|
214
|
+
if valid:
|
215
|
+
compiled_css.to_css()
|
216
|
+
else:
|
217
|
+
with pytest.raises(ValueError):
|
218
|
+
compiled_css.to_css()
|
@@ -11,31 +11,49 @@
|
|
11
11
|
# =======================================================================
|
12
12
|
|
13
13
|
import itertools
|
14
|
-
from typing import Dict, List, Union
|
14
|
+
from typing import Callable, Dict, List, Union
|
15
15
|
from webwidgets.compilation.html.html_node import HTMLNode
|
16
|
+
from webwidgets.utility.representation import ReprMixin
|
16
17
|
from webwidgets.utility.validation import validate_css_identifier
|
17
18
|
|
18
19
|
|
19
|
-
class
|
20
|
+
class CSSRule(ReprMixin):
|
21
|
+
"""A rule in a style sheet.
|
22
|
+
"""
|
23
|
+
|
24
|
+
def __init__(self, name: str, declarations: Dict[str, str]):
|
25
|
+
"""Stores the name and declarations of the rule.
|
26
|
+
|
27
|
+
:param name: The name of the rule.
|
28
|
+
:type name: str
|
29
|
+
:param declarations: The CSS declarations for the rule, specified as a
|
30
|
+
dictionary where keys are property names and values are their
|
31
|
+
corresponding values. For example: `{'color': 'red'}`
|
32
|
+
:type declarations: Dict[str, str]
|
33
|
+
"""
|
34
|
+
super().__init__()
|
35
|
+
self.name = name
|
36
|
+
self.declarations = declarations
|
37
|
+
|
38
|
+
|
39
|
+
class CompiledCSS(ReprMixin):
|
20
40
|
"""A utility class to hold compiled CSS rules.
|
21
41
|
"""
|
22
42
|
|
23
|
-
def __init__(self, trees: List[HTMLNode], rules:
|
24
|
-
mapping: Dict[int, List[
|
43
|
+
def __init__(self, trees: List[HTMLNode], rules: List[CSSRule],
|
44
|
+
mapping: Dict[int, List[CSSRule]]):
|
25
45
|
"""Stores compiled CSS rules.
|
26
46
|
|
27
47
|
:param trees: The HTML trees at the origin of the compilation. These
|
28
48
|
are the elements that have been styled with CSS properties.
|
29
49
|
:type trees: List[HTMLNode]
|
30
|
-
:param rules: The compiled CSS rules
|
31
|
-
|
32
|
-
`{'r0': {'color': 'red'}}`.
|
33
|
-
:type rules: Dict[str, Dict[str, str]]
|
50
|
+
:param rules: The compiled CSS rules.
|
51
|
+
:type rules: List[CSSRule]
|
34
52
|
:param mapping: A dictionary mapping each node ID to a list of rules
|
35
|
-
that achieve the same style.
|
36
|
-
|
37
|
-
:type mapping: Dict[int, List[str]]
|
53
|
+
that achieve the same style.
|
54
|
+
:type mapping: Dict[int, List[CSSRule]]
|
38
55
|
"""
|
56
|
+
super().__init__()
|
39
57
|
self.trees = trees
|
40
58
|
self.rules = rules
|
41
59
|
self.mapping = mapping
|
@@ -44,9 +62,9 @@ class CompiledCSS:
|
|
44
62
|
"""Converts the `rules` dictionary of the :py:class:`CompiledCSS`
|
45
63
|
object into CSS code.
|
46
64
|
|
47
|
-
|
48
|
-
is validated with :py:func:`validate_css_identifier`
|
49
|
-
converted.
|
65
|
+
Rule names are converted to class selectors. Note that each rule and
|
66
|
+
property name is validated with :py:func:`validate_css_identifier`
|
67
|
+
before being converted.
|
50
68
|
|
51
69
|
:param indent_size: The number of spaces to use for indentation in the
|
52
70
|
CSS code. Defaults to 4.
|
@@ -58,10 +76,11 @@ class CompiledCSS:
|
|
58
76
|
css_code = ""
|
59
77
|
indentation = ' ' * indent_size
|
60
78
|
|
61
|
-
# Writing down each rule
|
62
|
-
for i,
|
63
|
-
|
64
|
-
|
79
|
+
# Writing down each rule
|
80
|
+
for i, rule in enumerate(self.rules):
|
81
|
+
validate_css_identifier(rule.name)
|
82
|
+
css_code += f".{rule.name}" + " {\n"
|
83
|
+
for property_name, value in rule.declarations.items():
|
65
84
|
validate_css_identifier(property_name)
|
66
85
|
css_code += f"{indentation}{property_name}: {value};\n"
|
67
86
|
css_code += "}" + ('\n\n' if i < len(self.rules) - 1 else '')
|
@@ -69,7 +88,9 @@ class CompiledCSS:
|
|
69
88
|
return css_code
|
70
89
|
|
71
90
|
|
72
|
-
def compile_css(trees: Union[HTMLNode, List[HTMLNode]]
|
91
|
+
def compile_css(trees: Union[HTMLNode, List[HTMLNode]],
|
92
|
+
rule_namer: Callable[[List[CSSRule], int],
|
93
|
+
str] = None) -> CompiledCSS:
|
73
94
|
"""Computes optimized CSS rules from the given HTML trees.
|
74
95
|
|
75
96
|
The main purpose of this function is to reduce the number of CSS rules
|
@@ -97,15 +118,31 @@ def compile_css(trees: Union[HTMLNode, List[HTMLNode]]) -> CompiledCSS:
|
|
97
118
|
|
98
119
|
>>> compiled_css = compile_css(tree)
|
99
120
|
>>> print(compiled_css.rules)
|
100
|
-
|
101
|
-
'r0'
|
102
|
-
'r1'
|
103
|
-
'r2'
|
104
|
-
|
121
|
+
[
|
122
|
+
CSSRule(name='r0', declarations={'color': 'blue'}),
|
123
|
+
CSSRule(name='r1', declarations={'margin': '0'}),
|
124
|
+
CSSRule(name='r2', declarations={'padding': '0'})
|
125
|
+
]
|
105
126
|
|
106
127
|
:param trees: A single tree or a list of trees to optimize over. All
|
107
128
|
children are recursively included in the compilation.
|
108
129
|
:type trees: Union[HTMLNode, List[HTMLNode]]
|
130
|
+
:param rule_namer: A callable that takes two arguments, which are the list
|
131
|
+
of all compiled rules and an index within that list, and returns a
|
132
|
+
unique name for the rule at the given index.
|
133
|
+
|
134
|
+
This argument allows to customize the rule naming process and use names
|
135
|
+
other than the default `"r0"`, `"r1"`, etc. For example, it can be used
|
136
|
+
to achieve something similar to Tailwind CSS and name rules according
|
137
|
+
to what they achieve, e.g. by prefixing their name with `"m"` for
|
138
|
+
margin rules or `"p"` for padding rules. Note that all rule names will
|
139
|
+
be validated with the :py:func:`validate_css_identifier` function
|
140
|
+
before being written into CSS code.
|
141
|
+
|
142
|
+
Defaults to the :py:func:`default_rule_namer` function which implements
|
143
|
+
a default naming strategy where each rule is named `"r{i}"` where `i`
|
144
|
+
is the index of the rule in the list.
|
145
|
+
:type rule_namer: Callable[[List[CSSRule], int], str]
|
109
146
|
:return: The :py:class:`CompiledCSS` object containing the optimized rules.
|
110
147
|
Every HTML node present in one or more of the input trees is included
|
111
148
|
in the :py:attr:`CompiledCSS.mapping` attribute, even if the node does
|
@@ -117,14 +154,21 @@ def compile_css(trees: Union[HTMLNode, List[HTMLNode]]) -> CompiledCSS:
|
|
117
154
|
if isinstance(trees, HTMLNode):
|
118
155
|
trees = [trees]
|
119
156
|
|
157
|
+
# Handling default rule_namer
|
158
|
+
rule_namer = default_rule_namer if rule_namer is None else rule_namer
|
159
|
+
|
120
160
|
# For now, we just return a simple mapping where each CSS property defines
|
121
161
|
# its own ruleset
|
122
162
|
styles = {k: v for tree in trees for k, v in tree.get_styles().items()}
|
123
163
|
properties = set(itertools.chain.from_iterable(s.items()
|
124
164
|
for s in styles.values()))
|
125
|
-
rules =
|
126
|
-
|
127
|
-
|
165
|
+
rules = [CSSRule(None, dict([p])) # Initializing with no name
|
166
|
+
for p in sorted(properties)]
|
167
|
+
for i, rule in enumerate(rules): # Assigning name from callback
|
168
|
+
rule.name = rule_namer(rules, i)
|
169
|
+
rules = sorted(rules, key=lambda r: r.name) # Sorting by name
|
170
|
+
mapping = {node_id: [r for r in rules if
|
171
|
+
set(r.declarations.items()).issubset(style.items())]
|
128
172
|
for node_id, style in styles.items()}
|
129
173
|
return CompiledCSS(trees, rules, mapping)
|
130
174
|
|
@@ -156,7 +200,7 @@ def apply_css(css: CompiledCSS, tree: HTMLNode) -> None:
|
|
156
200
|
|
157
201
|
# Listing rules to add as classes. We do not add rules that are already
|
158
202
|
# there.
|
159
|
-
rules_to_add = [r for r in css.mapping[id(tree)] if r
|
203
|
+
rules_to_add = [r.name for r in css.mapping[id(tree)] if r.name not in
|
160
204
|
tree.attributes.get('class', '').split(' ')]
|
161
205
|
|
162
206
|
# Updating the class attribute. If it already exists and is not empty,
|
@@ -169,3 +213,17 @@ def apply_css(css: CompiledCSS, tree: HTMLNode) -> None:
|
|
169
213
|
# Recursively applying the CSS rules to all child nodes of the tree
|
170
214
|
for child in tree.children:
|
171
215
|
apply_css(css, child)
|
216
|
+
|
217
|
+
|
218
|
+
def default_rule_namer(rules: List[CSSRule], index: int) -> str:
|
219
|
+
"""Default rule naming function. Returns a string like "r{i}" where {i} is
|
220
|
+
the index of the rule.
|
221
|
+
|
222
|
+
:param rules: List of all compiled CSSRule objects. This argument is not
|
223
|
+
used in this function, but it can be used in other naming strategies.
|
224
|
+
:type rules: List[CSSRule]
|
225
|
+
:param index: Index of the rule being named.
|
226
|
+
:type index: int
|
227
|
+
:return: A string like `"r{i}"` where `i` is the index of the rule.
|
228
|
+
"""
|
229
|
+
return f'r{index}'
|
@@ -13,11 +13,12 @@
|
|
13
13
|
import copy
|
14
14
|
import itertools
|
15
15
|
from typing import Any, Dict, List, Union
|
16
|
+
from webwidgets.utility.representation import ReprMixin
|
16
17
|
from webwidgets.utility.sanitizing import sanitize_html_text
|
17
18
|
from webwidgets.utility.validation import validate_html_class
|
18
19
|
|
19
20
|
|
20
|
-
class HTMLNode:
|
21
|
+
class HTMLNode(ReprMixin):
|
21
22
|
"""Represents an HTML node (for example, a div or a span).
|
22
23
|
"""
|
23
24
|
|
@@ -34,6 +35,7 @@ class HTMLNode:
|
|
34
35
|
:param style: Dictionary of CSS properties for the node. Defaults to an empty dictionary.
|
35
36
|
:type style: Dict[str, str]
|
36
37
|
"""
|
38
|
+
super().__init__()
|
37
39
|
self.children = [] if children is None else children
|
38
40
|
self.attributes = {} if attributes is None else attributes
|
39
41
|
self.style = {} if style is None else style
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# =======================================================================
|
2
|
+
#
|
3
|
+
# This file is part of WebWidgets, a Python package for designing web
|
4
|
+
# UIs.
|
5
|
+
#
|
6
|
+
# You should have received a copy of the MIT License along with
|
7
|
+
# WebWidgets. If not, see <https://opensource.org/license/mit>.
|
8
|
+
#
|
9
|
+
# Copyright(C) 2025, mlaasri
|
10
|
+
#
|
11
|
+
# =======================================================================
|
12
|
+
|
13
|
+
class ReprMixin:
|
14
|
+
"""A mixin class that is represented with its variables when printed.
|
15
|
+
|
16
|
+
For example:
|
17
|
+
|
18
|
+
>>> class MyClass(RepresentedWithVars):
|
19
|
+
... def __init__(self, a, b):
|
20
|
+
... self.a = a
|
21
|
+
... self.b = b
|
22
|
+
>>> obj = MyClass(1, 2)
|
23
|
+
>>> print(obj)
|
24
|
+
MyClass(a=1, b=2)
|
25
|
+
"""
|
26
|
+
|
27
|
+
def __repr__(self) -> str:
|
28
|
+
"""Returns a string exposing all member variables of the class.
|
29
|
+
|
30
|
+
:return: A string representing the class with its variables.
|
31
|
+
:rtype: str
|
32
|
+
"""
|
33
|
+
variables = ', '.join(f'{k}={repr(v)}' for k, v in vars(self).items())
|
34
|
+
return f"{self.__class__.__name__}({variables})"
|
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
|