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.
Files changed (28) hide show
  1. {webwidgets-0.2.0 → webwidgets-0.2.1}/PKG-INFO +1 -1
  2. {webwidgets-0.2.0 → webwidgets-0.2.1}/tests/compilation/test_css.py +144 -69
  3. webwidgets-0.2.1/tests/utility/test_representation.py +118 -0
  4. {webwidgets-0.2.0 → webwidgets-0.2.1}/tests/utility/test_validation.py +65 -1
  5. {webwidgets-0.2.0 → webwidgets-0.2.1}/webwidgets/__init__.py +1 -1
  6. {webwidgets-0.2.0 → webwidgets-0.2.1}/webwidgets/compilation/css/__init__.py +2 -1
  7. {webwidgets-0.2.0 → webwidgets-0.2.1}/webwidgets/compilation/css/css.py +86 -28
  8. {webwidgets-0.2.0 → webwidgets-0.2.1}/webwidgets/compilation/html/html_node.py +3 -1
  9. {webwidgets-0.2.0 → webwidgets-0.2.1}/webwidgets/utility/__init__.py +1 -0
  10. webwidgets-0.2.1/webwidgets/utility/representation.py +34 -0
  11. {webwidgets-0.2.0 → webwidgets-0.2.1}/.github/workflows/cd.yml +0 -0
  12. {webwidgets-0.2.0 → webwidgets-0.2.1}/.github/workflows/ci-full.yml +0 -0
  13. {webwidgets-0.2.0 → webwidgets-0.2.1}/.github/workflows/ci-quick.yml +0 -0
  14. {webwidgets-0.2.0 → webwidgets-0.2.1}/.gitignore +0 -0
  15. {webwidgets-0.2.0 → webwidgets-0.2.1}/LICENSE +0 -0
  16. {webwidgets-0.2.0 → webwidgets-0.2.1}/README.md +0 -0
  17. {webwidgets-0.2.0 → webwidgets-0.2.1}/pyproject.toml +0 -0
  18. {webwidgets-0.2.0 → webwidgets-0.2.1}/tests/__init__.py +0 -0
  19. {webwidgets-0.2.0 → webwidgets-0.2.1}/tests/compilation/__init__.py +0 -0
  20. {webwidgets-0.2.0 → webwidgets-0.2.1}/tests/compilation/test_html_node.py +0 -0
  21. {webwidgets-0.2.0 → webwidgets-0.2.1}/tests/compilation/test_html_tags.py +0 -0
  22. {webwidgets-0.2.0 → webwidgets-0.2.1}/tests/utility/__init__.py +0 -0
  23. {webwidgets-0.2.0 → webwidgets-0.2.1}/tests/utility/test_sanitizing.py +0 -0
  24. {webwidgets-0.2.0 → webwidgets-0.2.1}/webwidgets/compilation/__init__.py +0 -0
  25. {webwidgets-0.2.0 → webwidgets-0.2.1}/webwidgets/compilation/html/__init__.py +0 -0
  26. {webwidgets-0.2.0 → webwidgets-0.2.1}/webwidgets/compilation/html/html_tags.py +0 -0
  27. {webwidgets-0.2.0 → webwidgets-0.2.1}/webwidgets/utility/sanitizing.py +0 -0
  28. {webwidgets-0.2.0 → webwidgets-0.2.1}/webwidgets/utility/validation.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: webwidgets
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: A Python package for designing web UIs.
5
5
  Project-URL: Source code, https://github.com/mlaasri/WebWidgets
6
6
  Author: mlaasri
@@ -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, apply_css
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
- 'r0': {'a': '5'},
35
- 'r1': {'b': '4'}
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 compiled_css.rules == expected_rules
49
- assert compiled_css.mapping == expected_mapping
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 compiled_css2.rules == expected_rules
58
- assert compiled_css2.mapping == expected_mapping
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
- 'r0': {'color': 'blue'},
77
- 'r1': {'margin': '0'},
78
- 'r2': {'padding': '0'}
79
- }
80
- assert compiled_css.rules == expected_rules
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 compiled_css.mapping == expected_mapping
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
- 'r0': {'color': 'blue'},
109
- 'r1': {'margin': '0'},
110
- 'r2': {'margin': '5'},
111
- 'r3': {'padding': '0'}
112
- }
113
- assert compiled_css.rules == expected_rules
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 compiled_css.mapping == expected_mapping
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
- 'r0': {'color': 'red'},
151
- 'r1': {'margin': '10'},
152
- 'r2': {'margin': '5'},
153
- 'r3': {'padding': '0'}
154
- }
155
- assert compiled_css.rules == expected_rules
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 compiled_css.mapping == expected_mapping
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
- 'r0': {'a': '10'},
179
- 'r1': {'a': '5'},
180
- 'r2': {'b': '10'},
181
- 'r3': {'b': '4'},
182
- 'r4': {'c': '5'}
183
- }
184
- assert compiled_css.rules == expected_rules
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
- 'r0': {'a': '5'},
198
- 'r1': {'b': '10'},
199
- 'r2': {'b': '4'}
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 compiled_css.rules == expected_rules
210
- assert compiled_css.mapping == expected_mapping
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 compiled_css2.rules == expected_rules
220
- assert compiled_css2.mapping == expected_mapping
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
- "margin": "0",
228
- "padding": "0"
229
- },
230
- "r1": {
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()
@@ -10,6 +10,6 @@
10
10
  #
11
11
  # =======================================================================
12
12
 
13
- __version__ = "0.2.0" # Dynamically set by build backend
13
+ __version__ = "0.2.1" # Dynamically set by build backend
14
14
 
15
15
  from . import compilation
@@ -10,4 +10,5 @@
10
10
  #
11
11
  # =======================================================================
12
12
 
13
- from .css import compile_css, CompiledCSS, apply_css
13
+ from .css import compile_css, CSSRule, CompiledCSS, apply_css, \
14
+ default_rule_namer
@@ -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 CompiledCSS:
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: Dict[str, Dict[str, str]],
24
- mapping: Dict[int, List[str]]):
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, specified as a dictionary mapping
31
- the rule's name to its corresponding CSS declarations. For example:
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. Rules must be specified by their name.
36
- For example: `{123: ['r0', 'r2'], 456: ['r1']}`.
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
- Each rule name is converted to a class selector and each property name
48
- is validated with :py:func:`validate_css_identifier` before being
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 from the rules dictionary
62
- for i, (name, declarations) in enumerate(self.rules.items()):
63
- css_code += f".{name}" + " {\n"
64
- for property_name, value in declarations.items():
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]]) -> CompiledCSS:
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': {'color': 'blue'},
102
- 'r1': {'margin': '0'},
103
- 'r2': {'padding': '0'}
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 = {f"r{i}": dict([p]) for i, p in enumerate(sorted(properties))}
126
- mapping = {node_id: sorted([n for n, r in rules.items() if
127
- set(r.items()).issubset(style.items())])
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 and r not in
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
@@ -10,5 +10,6 @@
10
10
  #
11
11
  # =======================================================================
12
12
 
13
+ from .representation import *
13
14
  from .sanitizing import *
14
15
  from .validation import *
@@ -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