HTMLCompare 0.4.0__py3-none-any.whl

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.
@@ -0,0 +1,16 @@
1
+ # SPDX-License-Identifier: MIT
2
+
3
+ from htmlcompare.compare import Difference, compare_html
4
+ from htmlcompare.options import CompareOptions
5
+ from htmlcompare.result import ComparisonResult
6
+ from htmlcompare.testutils import assert_different_html, assert_same_html
7
+
8
+
9
+ __all__ = [
10
+ 'compare_html',
11
+ 'Difference',
12
+ 'CompareOptions',
13
+ 'ComparisonResult',
14
+ 'assert_different_html',
15
+ 'assert_same_html',
16
+ ]
htmlcompare/cli.py ADDED
@@ -0,0 +1,24 @@
1
+ # SPDX-License-Identifier: MIT
2
+
3
+ import sys
4
+
5
+ from .testutils import assert_same_html
6
+
7
+
8
+ __all__ = []
9
+
10
+ def htmlcompare_cli():
11
+ if len(sys.argv) < 3:
12
+ sys.stderr.write('usage: htmlcompare <EXPECTED> <ACTUAL>\n')
13
+ sys.exit(1)
14
+ expected_fn, actual_fn = sys.argv[1:3]
15
+ with open(expected_fn, 'rb') as expected_fp:
16
+ expected_html = expected_fp.read().decode('utf8')
17
+ with open(actual_fn, 'rb') as actual_fp:
18
+ actual_html = actual_fp.read().decode('utf8')
19
+ try:
20
+ assert_same_html(expected_html, actual_html, verbose=True)
21
+ except AssertionError:
22
+ raise
23
+ sys.exit(10)
24
+ print('HTML in both files is the same. :-)')
htmlcompare/compare.py ADDED
@@ -0,0 +1,325 @@
1
+ # SPDX-License-Identifier: MIT
2
+
3
+ from collections.abc import Sequence
4
+ from typing import Optional
5
+
6
+ from htmlcompare.compare_css import compare_css, compare_stylesheet
7
+ from htmlcompare.nodes import Comment, ConditionalComment, Document, Element, Node, TextNode
8
+ from htmlcompare.normalize import normalize_tree
9
+ from htmlcompare.options import CompareOptions
10
+ from htmlcompare.parser import parse_html
11
+ from htmlcompare.result import ComparisonResult, Difference, DifferenceType
12
+
13
+
14
+ __all__ = ['compare_html']
15
+
16
+
17
+ def compare_html(
18
+ expected_html: str,
19
+ actual_html: str,
20
+ options: Optional[CompareOptions] = None,
21
+ ) -> ComparisonResult:
22
+ """
23
+ Compare two HTML strings for equality.
24
+
25
+ This implementation uses a tree-based approach with normalization
26
+ to handle insignificant whitespace between block elements.
27
+ """
28
+ expected_tree = parse_html(expected_html)
29
+ actual_tree = parse_html(actual_html)
30
+
31
+ # normalize trees to remove insignificant whitespace
32
+ expected_normalized = normalize_tree(expected_tree, options)
33
+ actual_normalized = normalize_tree(actual_tree, options)
34
+ return _compare_trees(expected_normalized, actual_normalized)
35
+
36
+
37
+ def _compare_trees(expected: Document, actual: Document) -> ComparisonResult:
38
+ differences: list[Difference] = []
39
+ _compare_node_lists(expected.children, actual.children, "", differences, parent_tag=None)
40
+ _documents_are_equal = (len(differences) == 0)
41
+ return ComparisonResult(is_equal=_documents_are_equal, differences=differences)
42
+
43
+
44
+ def _compare_node_lists(
45
+ expected: Sequence[Node],
46
+ actual: Sequence[Node],
47
+ path: str,
48
+ differences: list[Difference],
49
+ *,
50
+ parent_tag: Optional[str] = None,
51
+ ) -> None:
52
+ max_len = max(len(expected), len(actual))
53
+
54
+ for i in range(max_len):
55
+ child_path = f"{path}[{i}]" if path else f"[{i}]"
56
+
57
+ if i >= len(expected):
58
+ # Extra node in actual
59
+ actual_node = actual[i]
60
+ differences.append(Difference(
61
+ type=DifferenceType.CHILD_EXTRA,
62
+ path=child_path,
63
+ expected=None,
64
+ actual=_node_summary(actual_node),
65
+ message=f"unexpected node: {_node_summary(actual_node)}",
66
+ ))
67
+ continue
68
+
69
+ if i >= len(actual):
70
+ # Missing node in actual
71
+ expected_node = expected[i]
72
+ differences.append(Difference(
73
+ type=DifferenceType.CHILD_MISSING,
74
+ path=child_path,
75
+ expected=_node_summary(expected_node),
76
+ actual=None,
77
+ message=f"missing node: {_node_summary(expected_node)}",
78
+ ))
79
+ continue
80
+
81
+ _compare_nodes(expected[i], actual[i], child_path, differences, parent_tag=parent_tag)
82
+
83
+
84
+ def _compare_nodes(
85
+ expected: Node,
86
+ actual: Node,
87
+ path: str,
88
+ differences: list[Difference],
89
+ *,
90
+ parent_tag: Optional[str] = None,
91
+ ) -> None:
92
+ if type(expected) is not type(actual):
93
+ differences.append(Difference(
94
+ type=DifferenceType.NODE_TYPE_MISMATCH,
95
+ path=path,
96
+ expected=type(expected).__name__,
97
+ actual=type(actual).__name__,
98
+ ))
99
+ return
100
+
101
+ if isinstance(expected, Element):
102
+ assert isinstance(actual, Element)
103
+ _compare_elements(expected, actual, path, differences)
104
+ elif isinstance(expected, TextNode):
105
+ assert isinstance(actual, TextNode)
106
+ _compare_text_nodes(expected, actual, path, differences, parent_tag=parent_tag)
107
+ elif isinstance(expected, Comment):
108
+ assert isinstance(actual, Comment)
109
+ _compare_comments(expected, actual, path, differences)
110
+ elif isinstance(expected, ConditionalComment):
111
+ assert isinstance(actual, ConditionalComment)
112
+ _compare_conditional_comments(expected, actual, path, differences)
113
+
114
+
115
+ def _compare_elements(
116
+ expected: Element,
117
+ actual: Element,
118
+ path: str,
119
+ differences: list[Difference],
120
+ ) -> None:
121
+ element_path = f"{path} > {expected.tag}" if path else expected.tag
122
+
123
+ # check tag names
124
+ if expected.tag != actual.tag:
125
+ differences.append(Difference(
126
+ type=DifferenceType.TAG_MISMATCH,
127
+ path=element_path,
128
+ expected=expected.tag,
129
+ actual=actual.tag,
130
+ ))
131
+ return # don't compare children if tags differ
132
+
133
+ _compare_attributes(expected.attributes, actual.attributes, element_path, differences)
134
+ # compare children, passing tag name for context-aware comparison (e.g., CSS in <style> tags)
135
+ _compare_node_lists(
136
+ expected.children,
137
+ actual.children,
138
+ element_path,
139
+ differences,
140
+ parent_tag=expected.tag,
141
+ )
142
+
143
+
144
+ def _compare_attributes(
145
+ expected: dict[str, str],
146
+ actual: dict[str, str],
147
+ path: str,
148
+ differences: list[Difference],
149
+ ) -> None:
150
+ expected_normalized = _normalize_attributes(expected)
151
+ actual_normalized = _normalize_attributes(actual)
152
+ all_keys = set(expected_normalized) | set(actual_normalized)
153
+ for key in sorted(all_keys):
154
+ if key not in expected_normalized:
155
+ differences.append(Difference(
156
+ type=DifferenceType.ATTRIBUTE_EXTRA,
157
+ path=f"{path}@{key}",
158
+ expected=None,
159
+ actual=actual_normalized[key],
160
+ message=f"unexpected attribute '{key}'",
161
+ ))
162
+ elif key not in actual_normalized:
163
+ differences.append(Difference(
164
+ type=DifferenceType.ATTRIBUTE_MISSING,
165
+ path=f"{path}@{key}",
166
+ expected=expected_normalized[key],
167
+ actual=None,
168
+ message=f"missing attribute '{key}'",
169
+ ))
170
+ elif key == 'class':
171
+ _compare_class_attribute(
172
+ expected_normalized[key], actual_normalized[key], path, differences
173
+ )
174
+ elif key == 'style':
175
+ _compare_style_attribute(
176
+ expected_normalized[key], actual_normalized[key], path, differences
177
+ )
178
+ elif expected_normalized[key] != actual_normalized[key]:
179
+ differences.append(Difference(
180
+ type=DifferenceType.ATTRIBUTE_MISMATCH,
181
+ path=f"{path}@{key}",
182
+ expected=expected_normalized[key],
183
+ actual=actual_normalized[key],
184
+ ))
185
+
186
+
187
+ def _normalize_attributes(attrs: dict[str, str]) -> dict[str, str]:
188
+ """Normalize attributes, removing empty class/style attributes."""
189
+ result = {}
190
+ for key, value in attrs.items():
191
+ # an empty class attribute is same as absent
192
+ if key == 'class' and not value.strip():
193
+ continue
194
+ # an empty style attribute is same as absent
195
+ if key == 'style' and not value.strip():
196
+ continue
197
+ result[key] = value
198
+ return result
199
+
200
+
201
+ def _compare_class_attribute(
202
+ expected: str,
203
+ actual: str,
204
+ path: str,
205
+ differences: list[Difference],
206
+ ) -> None:
207
+ expected_classes = set(expected.split())
208
+ actual_classes = set(actual.split())
209
+
210
+ missing = expected_classes - actual_classes
211
+ extra = actual_classes - expected_classes
212
+
213
+ if missing:
214
+ differences.append(Difference(
215
+ type=DifferenceType.CLASS_MISSING,
216
+ path=f"{path}@class",
217
+ expected=sorted(missing),
218
+ actual=None,
219
+ message=f"missing classes: {sorted(missing)}",
220
+ ))
221
+ if extra:
222
+ differences.append(Difference(
223
+ type=DifferenceType.CLASS_EXTRA,
224
+ path=f"{path}@class",
225
+ expected=None,
226
+ actual=sorted(extra),
227
+ message=f"unexpected classes: {sorted(extra)}",
228
+ ))
229
+
230
+
231
+ def _compare_style_attribute(
232
+ expected: str,
233
+ actual: str,
234
+ path: str,
235
+ differences: list[Difference],
236
+ ) -> None:
237
+ """Compare style attributes using CSS-aware comparison."""
238
+ if compare_css(expected, actual):
239
+ return # styles are equivalent
240
+
241
+ differences.append(Difference(
242
+ type=DifferenceType.STYLE_MISMATCH,
243
+ path=f"{path}@style",
244
+ expected=expected,
245
+ actual=actual,
246
+ ))
247
+
248
+
249
+ def _compare_text_nodes(
250
+ expected: TextNode,
251
+ actual: TextNode,
252
+ path: str,
253
+ differences: list[Difference],
254
+ *,
255
+ parent_tag: Optional[str] = None,
256
+ ) -> None:
257
+ if parent_tag == 'style':
258
+ if compare_stylesheet(expected.content, actual.content):
259
+ return # CSS is semantically equivalent
260
+ differences.append(Difference(
261
+ type=DifferenceType.TEXT_MISMATCH,
262
+ path=path,
263
+ expected=expected.content,
264
+ actual=actual.content,
265
+ ))
266
+ return
267
+
268
+ if expected.content != actual.content:
269
+ differences.append(Difference(
270
+ type=DifferenceType.TEXT_MISMATCH,
271
+ path=path,
272
+ expected=expected.content,
273
+ actual=actual.content,
274
+ ))
275
+
276
+
277
+ def _compare_comments(
278
+ expected: Comment,
279
+ actual: Comment,
280
+ path: str,
281
+ differences: list[Difference],
282
+ ) -> None:
283
+ if expected.content != actual.content:
284
+ differences.append(Difference(
285
+ type=DifferenceType.COMMENT_MISMATCH,
286
+ path=path,
287
+ expected=expected.content,
288
+ actual=actual.content,
289
+ ))
290
+
291
+
292
+ def _compare_conditional_comments(
293
+ expected: ConditionalComment,
294
+ actual: ConditionalComment,
295
+ path: str,
296
+ differences: list[Difference],
297
+ ) -> None:
298
+ cc_path = f"{path} > <!--[if {expected.condition}]>" if path else f"<!--[if {expected.condition}]>" # noqa: E501
299
+
300
+ # Compare conditions
301
+ if expected.condition != actual.condition:
302
+ differences.append(Difference(
303
+ type=DifferenceType.CONDITIONAL_COMMENT_CONDITION_MISMATCH,
304
+ path=cc_path,
305
+ expected=expected.condition,
306
+ actual=actual.condition,
307
+ ))
308
+ return # don't compare children if conditions differ
309
+
310
+ # Compare children
311
+ _compare_node_lists(expected.children, actual.children, cc_path, differences)
312
+
313
+
314
+ def _node_summary(node: Node) -> str:
315
+ if isinstance(node, Element):
316
+ return f"<{node.tag}>"
317
+ elif isinstance(node, TextNode):
318
+ content = node.content[:20] + "..." if len(node.content) > 20 else node.content
319
+ return f"text({content!r})"
320
+ elif isinstance(node, Comment):
321
+ content = node.content[:20] + "..." if len(node.content) > 20 else node.content
322
+ return f"comment({content!r})"
323
+ elif isinstance(node, ConditionalComment):
324
+ return f"<!--[if {node.condition}]>..."
325
+ return str(type(node).__name__)
@@ -0,0 +1,163 @@
1
+ # SPDX-License-Identifier: MIT
2
+
3
+ from operator import attrgetter
4
+
5
+ import tinycss2
6
+ from tinycss2.ast import AtRule, Declaration, NumberToken, QualifiedRule
7
+
8
+
9
+ __all__ = ['compare_css', 'compare_stylesheet']
10
+
11
+ def compare_css(expected_css, actual_css):
12
+ _e_css = normalize_css(expected_css)
13
+ _a_css = normalize_css(actual_css)
14
+ _e_css_str = tinycss2.serialize(_e_css)
15
+ _a_css_str = tinycss2.serialize(_a_css)
16
+ return _e_css_str == _a_css_str
17
+
18
+
19
+ def compare_stylesheet(expected_css, actual_css):
20
+ _e_css_str = tinycss2.serialize(normalize_stylesheet(expected_css))
21
+ _a_css_str = tinycss2.serialize(normalize_stylesheet(actual_css))
22
+ return _e_css_str == _a_css_str
23
+
24
+
25
+ def is_dimension(token):
26
+ return (token.type == 'dimension')
27
+
28
+ def is_whitespace(token):
29
+ return (token.type == 'whitespace')
30
+
31
+ def _strip_whitespace(all_tokens):
32
+ tokens = []
33
+ for token in all_tokens:
34
+ if is_whitespace(token):
35
+ continue
36
+ tokens.append(token)
37
+ return tokens
38
+
39
+ def _strip_zero_units(all_tokens):
40
+ tokens = []
41
+ for token in all_tokens:
42
+ if is_dimension(token) and token.int_value == 0:
43
+ token = NumberToken(
44
+ token.source_line,
45
+ token.source_column,
46
+ token.value,
47
+ token.int_value,
48
+ token.representation,
49
+ )
50
+ tokens.append(token)
51
+ return tokens
52
+
53
+ def normalize_css(css_declaration_str):
54
+ _decls = []
55
+ _css_decls = tinycss2.parse_declaration_list(
56
+ css_declaration_str, skip_comments=True, skip_whitespace=True
57
+ )
58
+ for decl in _css_decls:
59
+ assert (decl.type == 'declaration'), decl
60
+ tokens = _strip_whitespace(decl.value)
61
+ tokens = _strip_zero_units(tokens)
62
+ _decl = Declaration(
63
+ line = decl.source_line,
64
+ column = decl.source_column,
65
+ name = decl.name,
66
+ lower_name = decl.lower_name,
67
+ value = tokens,
68
+ important = decl.important
69
+ )
70
+ _decls.append(_decl)
71
+
72
+ sorted_decls = sorted(_decls, key=attrgetter('name'))
73
+ return tuple(sorted_decls)
74
+
75
+
76
+ def normalize_stylesheet(css_str):
77
+ """Normalize a CSS stylesheet (with selectors and rules) for comparison.
78
+
79
+ Unlike normalize_css() which handles declaration lists (for style attributes),
80
+ this function handles full stylesheets with selectors like:
81
+ body { margin: 0; }
82
+ .foo { color: red; }
83
+ @media screen { .foo { color: blue; } }
84
+ """
85
+ rules = tinycss2.parse_stylesheet(css_str, skip_comments=True, skip_whitespace=True)
86
+ return _normalize_rule_list(rules)
87
+
88
+
89
+ def _normalize_rule_list(rules):
90
+ """Normalize a list of CSS rules (qualified rules, at-rules, etc.)."""
91
+ normalized_rules = []
92
+
93
+ for rule in rules:
94
+ if rule.type == 'qualified-rule':
95
+ normalized_rule = _normalize_qualified_rule(rule)
96
+ normalized_rules.append(normalized_rule)
97
+ elif rule.type == 'at-rule':
98
+ normalized_rule = _normalize_at_rule(rule)
99
+ normalized_rules.append(normalized_rule)
100
+ elif rule.type == 'error':
101
+ # keep errors for debugging
102
+ normalized_rules.append(rule)
103
+
104
+ return tuple(normalized_rules)
105
+
106
+
107
+ def _normalize_qualified_rule(rule):
108
+ """Normalize a qualified rule (selector { declarations })."""
109
+ prelude = _strip_whitespace(rule.prelude)
110
+
111
+ # parse and normalize the content (declarations)
112
+ content_decls = tinycss2.parse_declaration_list(
113
+ rule.content, skip_comments=True, skip_whitespace=True
114
+ )
115
+ normalized_decls = []
116
+ for decl in content_decls:
117
+ if decl.type == 'declaration':
118
+ tokens = _strip_whitespace(decl.value)
119
+ tokens = _strip_zero_units(tokens)
120
+ _decl = Declaration(
121
+ line = decl.source_line,
122
+ column = decl.source_column,
123
+ name = decl.name,
124
+ lower_name = decl.lower_name,
125
+ value = tokens,
126
+ important = decl.important
127
+ )
128
+ normalized_decls.append(_decl)
129
+
130
+ # sort declarations by name for order-independent comparison
131
+ sorted_decls = sorted(normalized_decls, key=attrgetter('name'))
132
+
133
+ return QualifiedRule(
134
+ rule.source_line,
135
+ rule.source_column,
136
+ prelude,
137
+ sorted_decls,
138
+ )
139
+
140
+
141
+ def _normalize_at_rule(rule):
142
+ """Normalize an at-rule (@media, @keyframes, etc.)."""
143
+ prelude = _strip_whitespace(rule.prelude)
144
+
145
+ # normalize the content if it contains nested rules (like @media)
146
+ if rule.content is not None:
147
+ content_rules = tinycss2.parse_rule_list(
148
+ rule.content,
149
+ skip_comments=True,
150
+ skip_whitespace=True,
151
+ )
152
+ normalized_content = list(_normalize_rule_list(content_rules))
153
+ else:
154
+ normalized_content = None
155
+
156
+ return AtRule(
157
+ rule.source_line,
158
+ rule.source_column,
159
+ rule.at_keyword,
160
+ rule.lower_at_keyword,
161
+ prelude,
162
+ normalized_content,
163
+ )
@@ -0,0 +1,69 @@
1
+ # SPDX-License-Identifier: MIT
2
+
3
+
4
+ __all__ = ['is_block_element', 'is_inline_element', 'is_preformatted_element']
5
+
6
+ # Block-level elements where whitespace between them is typically insignificant.
7
+ # Based on HTML5 spec and browser rendering behavior.
8
+ BLOCK_ELEMENTS = frozenset({
9
+ # Document sections
10
+ 'html', 'head', 'body',
11
+ # Content sectioning
12
+ 'address', 'article', 'aside', 'footer', 'header', 'hgroup',
13
+ 'main', 'nav', 'section',
14
+ # Text content (block)
15
+ 'blockquote', 'dd', 'div', 'dl', 'dt', 'figcaption', 'figure',
16
+ 'hr', 'li', 'menu', 'ol', 'p', 'pre', 'ul',
17
+ # Table content
18
+ 'caption', 'col', 'colgroup', 'table', 'tbody', 'td', 'tfoot',
19
+ 'th', 'thead', 'tr',
20
+ # Form elements (block-like)
21
+ 'fieldset', 'form', 'legend', 'optgroup', 'option',
22
+ # Other block elements
23
+ 'details', 'dialog', 'summary',
24
+ # Headings
25
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
26
+ # Metadata (typically not rendered)
27
+ 'base', 'link', 'meta', 'noscript', 'script', 'style', 'template', 'title',
28
+ # VML elements (used in Outlook conditional comments)
29
+ 'v:image', 'v:rect', 'v:fill', 'v:stroke', 'v:textbox', 'v:shape',
30
+ 'v:shapetype', 'v:roundrect', 'v:oval', 'v:line', 'v:polyline',
31
+ 'v:group', 'v:background',
32
+ # Microsoft Office elements
33
+ 'o:p', 'o:wrapblock',
34
+ })
35
+
36
+ # Inline elements where whitespace is significant for rendering.
37
+ INLINE_ELEMENTS = frozenset({
38
+ # Text semantics
39
+ 'a', 'abbr', 'b', 'bdi', 'bdo', 'br', 'cite', 'code', 'data',
40
+ 'dfn', 'em', 'i', 'kbd', 'mark', 'q', 'rp', 'rt', 'ruby',
41
+ 's', 'samp', 'small', 'span', 'strong', 'sub', 'sup', 'time',
42
+ 'u', 'var', 'wbr',
43
+ # Edits
44
+ 'del', 'ins',
45
+ # Embedded content (inline by default)
46
+ 'audio', 'canvas', 'embed', 'iframe', 'img', 'math', 'object',
47
+ 'picture', 'svg', 'video',
48
+ # Form elements (inline by default)
49
+ 'button', 'datalist', 'input', 'label', 'meter', 'output',
50
+ 'progress', 'select', 'textarea',
51
+ })
52
+
53
+ # Elements that preserve whitespace (like <pre>).
54
+ PREFORMATTED_ELEMENTS = frozenset({
55
+ 'pre', 'code', 'textarea', 'script', 'style',
56
+ })
57
+
58
+
59
+ def is_block_element(tag: str) -> bool:
60
+ return tag.lower() in BLOCK_ELEMENTS
61
+
62
+
63
+ def is_inline_element(tag: str) -> bool:
64
+ return tag.lower() in INLINE_ELEMENTS
65
+
66
+
67
+ def is_preformatted_element(tag: str) -> bool:
68
+ """Return True if the element preserves whitespace."""
69
+ return tag.lower() in PREFORMATTED_ELEMENTS
htmlcompare/nodes.py ADDED
@@ -0,0 +1,78 @@
1
+ # SPDX-License-Identifier: MIT
2
+
3
+ from collections.abc import Sequence
4
+ from dataclasses import dataclass, field
5
+ from typing import Union
6
+
7
+
8
+ __all__ = ['Node', 'Element', 'TextNode', 'Comment', 'ConditionalComment', 'Document']
9
+
10
+
11
+ @dataclass
12
+ class TextNode:
13
+ """Represents text content in HTML."""
14
+ content: str
15
+
16
+ def __eq__(self, other):
17
+ if not isinstance(other, TextNode):
18
+ return NotImplemented
19
+ return self.content == other.content
20
+
21
+
22
+ @dataclass
23
+ class Comment:
24
+ """Represents an HTML comment."""
25
+ content: str
26
+
27
+ def __eq__(self, other):
28
+ if not isinstance(other, Comment):
29
+ return NotImplemented
30
+ return self.content == other.content
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class ConditionalComment:
35
+ """
36
+ Represents an IE conditional comment.
37
+
38
+ Example: <!--[if IE]><p>IE only</p><![endif]-->
39
+ """
40
+ condition: str # e.g., "IE", "lt IE 9", "gte IE 8"
41
+ children: list['Node'] = field(default_factory=list)
42
+
43
+ def __eq__(self, other):
44
+ if not isinstance(other, ConditionalComment):
45
+ return NotImplemented
46
+ return self.condition == other.condition and self.children == other.children
47
+
48
+
49
+ @dataclass
50
+ class Element:
51
+ """Represents an HTML element with tag, attributes, and children."""
52
+ tag: str
53
+ attributes: dict[str, str] = field(default_factory=dict)
54
+ children: Sequence['Node'] = field(default_factory=list)
55
+
56
+ def __eq__(self, other):
57
+ if not isinstance(other, Element):
58
+ return NotImplemented
59
+ return (
60
+ self.tag == other.tag
61
+ and self.attributes == other.attributes
62
+ and self.children == other.children
63
+ )
64
+
65
+
66
+ @dataclass
67
+ class Document:
68
+ """Represents a parsed HTML document (list of root nodes)."""
69
+ children: list['Node'] = field(default_factory=list)
70
+
71
+ def __eq__(self, other):
72
+ if not isinstance(other, Document):
73
+ return NotImplemented
74
+ return self.children == other.children
75
+
76
+
77
+ # Type alias for any node type
78
+ Node = Union[Element, TextNode, Comment, ConditionalComment]