semdiff-html 0.1.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.
- semdiff/__init__.py +32 -0
- semdiff/__main__.py +5 -0
- semdiff/_models.py +9 -0
- semdiff/cli.py +147 -0
- semdiff/config.py +74 -0
- semdiff/errors.py +76 -0
- semdiff/locator.py +141 -0
- semdiff/normalize/__init__.py +39 -0
- semdiff/normalize/api.py +25 -0
- semdiff/normalize/model.py +197 -0
- semdiff/normalize/registry.py +321 -0
- semdiff/normalize/rules/__init__.py +12 -0
- semdiff/normalize/rules/assets.py +79 -0
- semdiff/normalize/rules/canonical.py +182 -0
- semdiff/normalize/rules/classes.py +50 -0
- semdiff/normalize/rules/ids.py +94 -0
- semdiff/normalize/rules/timestamps.py +50 -0
- semdiff/normalize/rules/tokens.py +72 -0
- semdiff/parse.py +119 -0
- semdiff/py.typed +0 -0
- semdiff_html-0.1.0.dist-info/METADATA +254 -0
- semdiff_html-0.1.0.dist-info/RECORD +25 -0
- semdiff_html-0.1.0.dist-info/WHEEL +4 -0
- semdiff_html-0.1.0.dist-info/entry_points.txt +2 -0
- semdiff_html-0.1.0.dist-info/licenses/LICENSE +202 -0
semdiff/__init__.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""SemDiff: semantic HTML change detection."""
|
|
2
|
+
|
|
3
|
+
from semdiff.config import Config, NormalizationConfig, ParserBackend
|
|
4
|
+
from semdiff.errors import (
|
|
5
|
+
AlignmentBudgetExceeded,
|
|
6
|
+
ErrorRecord,
|
|
7
|
+
InputTooLargeError,
|
|
8
|
+
ParseError,
|
|
9
|
+
SemDiffError,
|
|
10
|
+
)
|
|
11
|
+
from semdiff.normalize.api import normalize
|
|
12
|
+
from semdiff.parse import PARSER_ID, PARSER_VERSION, ParsedDoc, parse, parser_fingerprint
|
|
13
|
+
|
|
14
|
+
__version__ = "0.1.0" # the one source; Hatchling reads it at build time (D-050)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"PARSER_ID",
|
|
18
|
+
"PARSER_VERSION",
|
|
19
|
+
"AlignmentBudgetExceeded",
|
|
20
|
+
"Config",
|
|
21
|
+
"ErrorRecord",
|
|
22
|
+
"InputTooLargeError",
|
|
23
|
+
"NormalizationConfig",
|
|
24
|
+
"ParseError",
|
|
25
|
+
"ParsedDoc",
|
|
26
|
+
"ParserBackend",
|
|
27
|
+
"SemDiffError",
|
|
28
|
+
"__version__",
|
|
29
|
+
"normalize",
|
|
30
|
+
"parse",
|
|
31
|
+
"parser_fingerprint",
|
|
32
|
+
]
|
semdiff/__main__.py
ADDED
semdiff/_models.py
ADDED
semdiff/cli.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""Command line interface (T-20, FR-29): ``semdiff normalize file.html``.
|
|
2
|
+
|
|
3
|
+
A thin shell over the T-19 API. stdout carries normalized HTML and nothing else, so it
|
|
4
|
+
can always be redirected into a file; diagnostics and the ``--report`` summary go to
|
|
5
|
+
stderr. Every typed error from T-02 maps to its own exit code, so a script can tell an
|
|
6
|
+
oversized input from a parse failure without reading the message.
|
|
7
|
+
|
|
8
|
+
Rule provenance comes from ``apply_rules`` as D-033 anticipated — there is no second
|
|
9
|
+
reporting model.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import sys
|
|
16
|
+
from collections import Counter
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from semdiff import Config, NormalizationConfig, SemDiffError, __version__
|
|
21
|
+
from semdiff.normalize import BUILTIN_RULES, NormalizedDoc, apply_rules
|
|
22
|
+
from semdiff.parse import parse, parser_fingerprint
|
|
23
|
+
|
|
24
|
+
EXIT_OK = 0
|
|
25
|
+
EXIT_ERROR = 1
|
|
26
|
+
EXIT_USAGE = 2 # argparse's own code for a bad command line
|
|
27
|
+
EXIT_PARSE = 3
|
|
28
|
+
EXIT_TOO_LARGE = 4
|
|
29
|
+
EXIT_CONFIG = 5
|
|
30
|
+
EXIT_IO = 6
|
|
31
|
+
|
|
32
|
+
_EXIT_BY_ERROR_TYPE = {"parse_error": EXIT_PARSE, "input_too_large": EXIT_TOO_LARGE}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _read_stdin() -> bytes:
|
|
36
|
+
"""Raw bytes, so the parser's encoding detection (T-10) still applies."""
|
|
37
|
+
return sys.stdin.buffer.read()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _write_stdout(text: str) -> None:
|
|
41
|
+
"""Always UTF-8, whatever the console claims to be: the output is a document."""
|
|
42
|
+
sys.stdout.buffer.write(text.encode("utf-8"))
|
|
43
|
+
sys.stdout.buffer.flush()
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _fail(message: str, code: int) -> int:
|
|
47
|
+
print(f"semdiff: {message}", file=sys.stderr)
|
|
48
|
+
return code
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load_config(args: argparse.Namespace) -> Config:
|
|
52
|
+
config = Config()
|
|
53
|
+
if args.config is not None:
|
|
54
|
+
config = Config.from_json(Path(args.config).read_text(encoding="utf-8"))
|
|
55
|
+
normalization = NormalizationConfig(
|
|
56
|
+
disabled_rules=config.normalization.disabled_rules | frozenset(args.disable_rule),
|
|
57
|
+
enabled_rules=config.normalization.enabled_rules | frozenset(args.enable_rule),
|
|
58
|
+
)
|
|
59
|
+
return config.model_copy(update={"normalization": normalization})
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _report(result: NormalizedDoc) -> str:
|
|
63
|
+
"""One line per rule that fired, in the order the rules ran, with application counts.
|
|
64
|
+
|
|
65
|
+
The parser build is named first: it is the one input to a result that the config does not
|
|
66
|
+
cover (D-046), so a report that omitted it could not be reproduced from what it printed.
|
|
67
|
+
"""
|
|
68
|
+
header = f"parser {parser_fingerprint()}"
|
|
69
|
+
counts = Counter(application.rule_id for application in result.applied_rules)
|
|
70
|
+
if not counts:
|
|
71
|
+
return f"{header}\nno rules fired\n"
|
|
72
|
+
width = max(len(rule_id) for rule_id in counts)
|
|
73
|
+
lines = [header, f"{len(counts)} rules fired, {sum(counts.values())} applications"]
|
|
74
|
+
lines += [f" {rule_id:<{width}} {count}" for rule_id, count in counts.items()]
|
|
75
|
+
return "\n".join(lines) + "\n"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _normalize(args: argparse.Namespace) -> int:
|
|
79
|
+
try:
|
|
80
|
+
config = _load_config(args)
|
|
81
|
+
except OSError as exc:
|
|
82
|
+
return _fail(f"cannot read config: {exc}", EXIT_IO)
|
|
83
|
+
except (ValueError, TypeError) as exc:
|
|
84
|
+
return _fail(f"invalid config: {exc}", EXIT_CONFIG)
|
|
85
|
+
|
|
86
|
+
try:
|
|
87
|
+
source = _read_stdin() if args.file in (None, "-") else Path(args.file).read_bytes()
|
|
88
|
+
except OSError as exc:
|
|
89
|
+
return _fail(f"cannot read input: {exc}", EXIT_IO)
|
|
90
|
+
|
|
91
|
+
try:
|
|
92
|
+
doc = parse(source, encoding=args.encoding, config=config)
|
|
93
|
+
result = apply_rules(doc, BUILTIN_RULES, config.normalization)
|
|
94
|
+
except SemDiffError as exc:
|
|
95
|
+
return _fail(f"{exc.type_id}: {exc.message}", _EXIT_BY_ERROR_TYPE.get(exc.type_id, EXIT_ERROR))
|
|
96
|
+
except ValueError as exc: # unknown rule id in a toggle
|
|
97
|
+
return _fail(f"invalid config: {exc}", EXIT_CONFIG)
|
|
98
|
+
|
|
99
|
+
html = result.tree.html or ""
|
|
100
|
+
try:
|
|
101
|
+
if args.output is None:
|
|
102
|
+
_write_stdout(html)
|
|
103
|
+
else:
|
|
104
|
+
Path(args.output).write_bytes(html.encode("utf-8"))
|
|
105
|
+
except OSError as exc:
|
|
106
|
+
return _fail(f"cannot write output: {exc}", EXIT_IO)
|
|
107
|
+
|
|
108
|
+
if args.report:
|
|
109
|
+
sys.stderr.write(_report(result))
|
|
110
|
+
return EXIT_OK
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
114
|
+
parser = argparse.ArgumentParser(prog="semdiff", description="Semantic HTML change detection.")
|
|
115
|
+
parser.add_argument(
|
|
116
|
+
"--version", action="version", version=f"{__version__} ({parser_fingerprint()})"
|
|
117
|
+
)
|
|
118
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
119
|
+
|
|
120
|
+
normalize_parser = commands.add_parser(
|
|
121
|
+
"normalize",
|
|
122
|
+
help="strip presentational noise from an HTML document and canonicalize it",
|
|
123
|
+
description="Write the normalized document to stdout, or to --output.",
|
|
124
|
+
)
|
|
125
|
+
normalize_parser.add_argument("file", nargs="?", help="input file; omit or pass - to read stdin")
|
|
126
|
+
normalize_parser.add_argument("-o", "--output", help="write to this file instead of stdout")
|
|
127
|
+
normalize_parser.add_argument("--report", action="store_true", help="list the rules that fired, on stderr")
|
|
128
|
+
normalize_parser.add_argument("--config", help="JSON config file (see Config.to_canonical_json)")
|
|
129
|
+
normalize_parser.add_argument(
|
|
130
|
+
"--disable-rule", action="append", default=[], metavar="ID", help="turn one rule off; repeatable"
|
|
131
|
+
)
|
|
132
|
+
normalize_parser.add_argument(
|
|
133
|
+
"--enable-rule", action="append", default=[], metavar="ID", help="turn one opt-in rule on; repeatable"
|
|
134
|
+
)
|
|
135
|
+
normalize_parser.add_argument("--encoding", help="override the input encoding instead of detecting it")
|
|
136
|
+
normalize_parser.set_defaults(handler=_normalize)
|
|
137
|
+
return parser
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def main(argv: list[str] | None = None) -> int:
|
|
141
|
+
args = build_parser().parse_args(argv)
|
|
142
|
+
handler: Callable[[argparse.Namespace], int] = args.handler
|
|
143
|
+
return handler(args)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
if __name__ == "__main__": # pragma: no cover
|
|
147
|
+
raise SystemExit(main())
|
semdiff/config.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Immutable, serializable pipeline configuration and its deterministic hash (T-01).
|
|
2
|
+
|
|
3
|
+
A ``Config`` plus the two input snapshots fully determines a diff's output (NFR-1).
|
|
4
|
+
``config_hash`` covers every config field and the engine-owned built-in ruleset
|
|
5
|
+
version, so a change to either the user's settings or a shipped rule's definition
|
|
6
|
+
is visible in the output (DOMAIN_MODEL §12-J).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
from enum import StrEnum
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from pydantic import field_serializer, model_validator
|
|
17
|
+
|
|
18
|
+
from semdiff._models import FrozenModel
|
|
19
|
+
|
|
20
|
+
class ParserBackend(StrEnum):
|
|
21
|
+
"""L0 parser selection (FR-2)."""
|
|
22
|
+
|
|
23
|
+
SELECTOLAX = "selectolax"
|
|
24
|
+
LXML = "lxml"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class NormalizationConfig(FrozenModel):
|
|
28
|
+
"""Per-rule toggles for L1 (FR-11). Built-in rules are enabled unless listed here."""
|
|
29
|
+
|
|
30
|
+
disabled_rules: frozenset[str] = frozenset()
|
|
31
|
+
enabled_rules: frozenset[str] = frozenset()
|
|
32
|
+
|
|
33
|
+
@field_serializer("disabled_rules", "enabled_rules")
|
|
34
|
+
def _sorted(self, value: frozenset[str]) -> list[str]:
|
|
35
|
+
return sorted(value)
|
|
36
|
+
|
|
37
|
+
@model_validator(mode="after")
|
|
38
|
+
def _no_overlap(self) -> NormalizationConfig:
|
|
39
|
+
overlap = self.disabled_rules & self.enabled_rules
|
|
40
|
+
if overlap:
|
|
41
|
+
raise ValueError(f"rules both enabled and disabled: {sorted(overlap)}")
|
|
42
|
+
return self
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class Config(FrozenModel):
|
|
46
|
+
"""The single configuration object threaded through every pipeline layer."""
|
|
47
|
+
|
|
48
|
+
parser: ParserBackend = ParserBackend.SELECTOLAX
|
|
49
|
+
max_input_bytes: int = 10 * 1024 * 1024 # NFR-7 ceiling; larger input raises InputTooLargeError
|
|
50
|
+
normalization: NormalizationConfig = NormalizationConfig()
|
|
51
|
+
|
|
52
|
+
def to_canonical_json(self) -> str:
|
|
53
|
+
"""Compact JSON with sorted keys and sorted sets; the only serialized form."""
|
|
54
|
+
return _canonical_json(self.model_dump(mode="json"))
|
|
55
|
+
|
|
56
|
+
@classmethod
|
|
57
|
+
def from_json(cls, text: str) -> Config:
|
|
58
|
+
return cls.model_validate_json(text)
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def config_hash(self) -> str:
|
|
62
|
+
# Local import: the registry depends on Config, so the dependency runs this way only here.
|
|
63
|
+
from semdiff.normalize.registry import builtin_ruleset_version
|
|
64
|
+
|
|
65
|
+
payload = {
|
|
66
|
+
"ruleset_version": builtin_ruleset_version(),
|
|
67
|
+
"config": self.model_dump(mode="json"),
|
|
68
|
+
}
|
|
69
|
+
digest = hashlib.sha256(_canonical_json(payload).encode("utf-8")).hexdigest()
|
|
70
|
+
return f"sha256:{digest}"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _canonical_json(data: Any) -> str:
|
|
74
|
+
return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
semdiff/errors.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Typed error hierarchy (T-02).
|
|
2
|
+
|
|
3
|
+
Every failure inside the pipeline is a ``SemDiffError`` subclass. Layers raise them;
|
|
4
|
+
the public API boundary converts them into ``ErrorRecord`` entries in ``errors[]``
|
|
5
|
+
so a caller never receives a partial diff presented as complete (FR-3, ARCHITECTURE
|
|
6
|
+
"Fail loud, fail typed").
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import ClassVar, Literal
|
|
12
|
+
|
|
13
|
+
from pydantic import field_serializer
|
|
14
|
+
|
|
15
|
+
from semdiff._models import FrozenModel
|
|
16
|
+
|
|
17
|
+
SnapshotLabel = Literal["old", "new"]
|
|
18
|
+
DetailValue = str | int | None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ErrorRecord(FrozenModel):
|
|
22
|
+
"""One element of ``DiffResult.errors``."""
|
|
23
|
+
|
|
24
|
+
type: str
|
|
25
|
+
message: str
|
|
26
|
+
snapshot: SnapshotLabel | None = None
|
|
27
|
+
detail: dict[str, DetailValue] = {}
|
|
28
|
+
|
|
29
|
+
@field_serializer("detail")
|
|
30
|
+
def _sorted_detail(self, value: dict[str, DetailValue]) -> dict[str, DetailValue]:
|
|
31
|
+
return dict(sorted(value.items()))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SemDiffError(Exception):
|
|
35
|
+
"""Root of the hierarchy. ``type_id`` is the stable id used in ``errors[].type``."""
|
|
36
|
+
|
|
37
|
+
type_id: ClassVar[str] = "semdiff_error"
|
|
38
|
+
|
|
39
|
+
def __init__(self, message: str, *, snapshot: SnapshotLabel | None = None) -> None:
|
|
40
|
+
super().__init__(message)
|
|
41
|
+
self.message = message
|
|
42
|
+
self.snapshot = snapshot
|
|
43
|
+
|
|
44
|
+
def detail(self) -> dict[str, DetailValue]:
|
|
45
|
+
return {}
|
|
46
|
+
|
|
47
|
+
def to_record(self) -> ErrorRecord:
|
|
48
|
+
return ErrorRecord(
|
|
49
|
+
type=self.type_id, message=self.message, snapshot=self.snapshot, detail=self.detail()
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ParseError(SemDiffError):
|
|
54
|
+
"""A snapshot could not be parsed into a tree (FR-3)."""
|
|
55
|
+
|
|
56
|
+
type_id = "parse_error"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class InputTooLargeError(SemDiffError):
|
|
60
|
+
"""A snapshot exceeds the documented input size ceiling (NFR-7)."""
|
|
61
|
+
|
|
62
|
+
type_id = "input_too_large"
|
|
63
|
+
|
|
64
|
+
def __init__(self, size: int, limit: int, *, snapshot: SnapshotLabel | None = None) -> None:
|
|
65
|
+
super().__init__(f"input is {size} bytes; ceiling is {limit} bytes", snapshot=snapshot)
|
|
66
|
+
self.size = size
|
|
67
|
+
self.limit = limit
|
|
68
|
+
|
|
69
|
+
def detail(self) -> dict[str, DetailValue]:
|
|
70
|
+
return {"size": self.size, "limit": self.limit}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AlignmentBudgetExceeded(SemDiffError):
|
|
74
|
+
"""Tree-edit-distance alignment ran past its budget (v0.3, T-75). Fields arrive with T-75."""
|
|
75
|
+
|
|
76
|
+
type_id = "alignment_budget_exceeded"
|
semdiff/locator.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""``Locator`` — a pointer into a Document (DOMAIN_MODEL §7). Stage-relative, never absolute."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
from selectolax.lexbor import LexborNode
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Locator:
|
|
13
|
+
xpath: str | None
|
|
14
|
+
css: str | None
|
|
15
|
+
node_hash: str # the node itself, not its subtree (D-044); survives reordering
|
|
16
|
+
|
|
17
|
+
@classmethod
|
|
18
|
+
def for_node(cls, node: LexborNode, cache: PathCache | None = None) -> Locator:
|
|
19
|
+
"""Locator for an element or text node as it is *right now* (pre-mutation).
|
|
20
|
+
|
|
21
|
+
Both fields are computed here and now, cache or no cache: a locator records the node as
|
|
22
|
+
the rule found it, which is why it is taken before the rewrite. ``cache`` only changes
|
|
23
|
+
how the css path is *arrived at* — see ``PathCache`` for the lifetime it is valid for.
|
|
24
|
+
"""
|
|
25
|
+
target = node if node.tag not in ("-text", "-comment") or node.parent is None else node.parent
|
|
26
|
+
css = _css_path(target) if cache is None else cache.path(target)
|
|
27
|
+
return cls(xpath=None, css=css, node_hash=_node_hash(node))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _segment(node: LexborNode) -> str:
|
|
31
|
+
"""``p`` or ``p:nth-child(2)`` — this element's own step, positional among element siblings."""
|
|
32
|
+
position = 1
|
|
33
|
+
sibling = node.prev
|
|
34
|
+
while sibling is not None:
|
|
35
|
+
if sibling.is_element_node:
|
|
36
|
+
position += 1
|
|
37
|
+
sibling = sibling.prev
|
|
38
|
+
tag = node.tag or ""
|
|
39
|
+
return tag if position == 1 else f"{tag}:nth-child({position})"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _at_root(node: LexborNode | None) -> bool:
|
|
43
|
+
"""Whether the walk upwards has run out of path to build."""
|
|
44
|
+
return node is None or node.tag in ("", "#document") or node.is_document_node
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _css_path(node: LexborNode) -> str:
|
|
48
|
+
"""``html > body > div:nth-child(2) > p`` — positional among element siblings, 1-based."""
|
|
49
|
+
parts: list[str] = []
|
|
50
|
+
current: LexborNode | None = node
|
|
51
|
+
while not _at_root(current):
|
|
52
|
+
assert current is not None
|
|
53
|
+
parts.append(_segment(current))
|
|
54
|
+
current = current.parent
|
|
55
|
+
return " > ".join(reversed(parts))
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PathCache:
|
|
59
|
+
"""Element css paths already computed during **one pass over one tree** (T-27).
|
|
60
|
+
|
|
61
|
+
``_css_path`` climbs to the root and counts previous siblings at every level: ~14 parent
|
|
62
|
+
hops but ~114 sibling hops per call on the corpus, and the engine makes one call per
|
|
63
|
+
application. Sibling applications share almost all of that walk, so an ancestor's path is
|
|
64
|
+
computed once and the walk stops at the first element already known.
|
|
65
|
+
|
|
66
|
+
Keyed by ``mem_id``, the underlying DOM node's address, **not** by the node: two wrappers
|
|
67
|
+
for the same node compare equal, but ``LexborNode.__eq__`` is proportional to the
|
|
68
|
+
serialized subtree — 765 µs for a 58 KB ``<html>`` against 0.6 µs for a leaf — so a
|
|
69
|
+
node-keyed cache spends more on dict probes than the walk it saves (F-032).
|
|
70
|
+
|
|
71
|
+
That key is an address, so a cache is only valid while every element it has seen is still
|
|
72
|
+
alive. Two rules keep it so, and both are the caller's to honour:
|
|
73
|
+
|
|
74
|
+
* one cache per pass, never reused across passes, so nothing survives a rule that may
|
|
75
|
+
reshape the tree between passes; and
|
|
76
|
+
* no cache at all for a pass that can destroy an element — ``DROP_NODE`` (``_apply_node``)
|
|
77
|
+
and user-supplied ``CANONICALIZE`` transforms, which the engine never hands one to.
|
|
78
|
+
|
|
79
|
+
Attribute rewrites, text replacement and comment removal cannot invalidate an entry: they
|
|
80
|
+
free text and comment nodes, which are never keys here, and they leave every element in
|
|
81
|
+
place. ``Locator.for_node`` resolves a text or comment node to its parent element before
|
|
82
|
+
asking for a path, so only elements are ever cached or looked up.
|
|
83
|
+
|
|
84
|
+
**Neither rule is enforced at runtime**, and that is a measured choice rather than an
|
|
85
|
+
oversight (T-27). Counting the tree's elements once per pass to detect a breach costs 58 ms
|
|
86
|
+
against the 192 ms this cache saves on the corpus, and reports the breach only after the
|
|
87
|
+
wrong paths have been handed out. Re-checking the queried node's own step on every hit
|
|
88
|
+
costs 59 ms and still misses the case that matters: when an *ancestor* shifts, the check
|
|
89
|
+
passes and the cache answers ``div:nth-child(3)`` for a node that is now
|
|
90
|
+
``div:nth-child(2)``. Both spend about 30% of the gain to buy a partial guard, so the
|
|
91
|
+
invariant stays a convention of this module and the engine's. What would catch a violation
|
|
92
|
+
is the evidence that already exists: ``tests/corpus/test_fused_attribute_equivalence.py``
|
|
93
|
+
and the per-rule reference engine recompute every locator with no cache at all, and
|
|
94
|
+
``tests/corpus/parser_contract.json`` pins every ``css`` value of all 40 snapshots.
|
|
95
|
+
"""
|
|
96
|
+
|
|
97
|
+
__slots__ = ("_paths",)
|
|
98
|
+
|
|
99
|
+
def __init__(self) -> None:
|
|
100
|
+
self._paths: dict[int, str] = {}
|
|
101
|
+
|
|
102
|
+
def path(self, node: LexborNode) -> str:
|
|
103
|
+
"""The same string ``_css_path`` returns, reusing any ancestor already computed."""
|
|
104
|
+
chain: list[LexborNode] = []
|
|
105
|
+
current: LexborNode | None = node
|
|
106
|
+
prefix = ""
|
|
107
|
+
while not _at_root(current):
|
|
108
|
+
assert current is not None
|
|
109
|
+
known = self._paths.get(current.mem_id)
|
|
110
|
+
if known is not None:
|
|
111
|
+
prefix = known
|
|
112
|
+
break
|
|
113
|
+
chain.append(current)
|
|
114
|
+
current = current.parent
|
|
115
|
+
for element in reversed(chain):
|
|
116
|
+
part = _segment(element)
|
|
117
|
+
prefix = f"{prefix} > {part}" if prefix else part
|
|
118
|
+
self._paths[element.mem_id] = prefix
|
|
119
|
+
return prefix
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _node_hash(node: LexborNode) -> str:
|
|
123
|
+
"""Fingerprint of the node *itself* — never of its descendants (D-044, F-030).
|
|
124
|
+
|
|
125
|
+
Hashing ``node.html`` made provenance order-dependent: the same rewrite of the same
|
|
126
|
+
attribute recorded a different hash depending on whether a descendant had already been
|
|
127
|
+
rewritten, which differs between one traversal per rule and the fused attribute pass. A
|
|
128
|
+
locator points at a node, so its fingerprint is a property of that node: the tag and
|
|
129
|
+
attributes of an element, the content of a text or comment node. It still survives the node
|
|
130
|
+
moving, and it still changes when the node itself changes.
|
|
131
|
+
"""
|
|
132
|
+
if node.tag == "-text":
|
|
133
|
+
raw = node.text_content or ""
|
|
134
|
+
elif node.is_element_node:
|
|
135
|
+
# ``repr`` of the tag and the sorted (name, value) pairs: deterministic, and quoted, so
|
|
136
|
+
# two different attribute sets cannot collide the way a flat join would. A boolean
|
|
137
|
+
# attribute hashes like an empty one, as the canonical serializer unifies them (D-032).
|
|
138
|
+
raw = repr((node.tag or "", sorted((n, v or "") for n, v in node.attributes.items())))
|
|
139
|
+
else:
|
|
140
|
+
raw = node.html or "" # a comment or doctype has no children to pull in
|
|
141
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""L1 — normalization: rule model, phase-ordered registry (T-12) and built-in families (T-13+)."""
|
|
2
|
+
|
|
3
|
+
from semdiff.normalize.api import normalize
|
|
4
|
+
from semdiff.normalize.model import (
|
|
5
|
+
PLACEHOLDER,
|
|
6
|
+
Action,
|
|
7
|
+
EntropyMatcher,
|
|
8
|
+
GroupMatcher,
|
|
9
|
+
Matcher,
|
|
10
|
+
NodePredicate,
|
|
11
|
+
NormalizationRule,
|
|
12
|
+
NormalizedDoc,
|
|
13
|
+
RegexMatcher,
|
|
14
|
+
RuleApplication,
|
|
15
|
+
RuleFamily,
|
|
16
|
+
Target,
|
|
17
|
+
)
|
|
18
|
+
from semdiff.normalize.registry import BUILTIN_RULES, RuleRegistry, apply_rules
|
|
19
|
+
|
|
20
|
+
import semdiff.normalize.rules # noqa: E402, F401 (registers the built-in families; must stay last)
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"BUILTIN_RULES",
|
|
24
|
+
"PLACEHOLDER",
|
|
25
|
+
"Action",
|
|
26
|
+
"EntropyMatcher",
|
|
27
|
+
"GroupMatcher",
|
|
28
|
+
"Matcher",
|
|
29
|
+
"NodePredicate",
|
|
30
|
+
"NormalizationRule",
|
|
31
|
+
"NormalizedDoc",
|
|
32
|
+
"RegexMatcher",
|
|
33
|
+
"RuleApplication",
|
|
34
|
+
"RuleFamily",
|
|
35
|
+
"RuleRegistry",
|
|
36
|
+
"Target",
|
|
37
|
+
"apply_rules",
|
|
38
|
+
"normalize",
|
|
39
|
+
]
|
semdiff/normalize/api.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""T-19: the public normalization entry point (FR-10).
|
|
2
|
+
|
|
3
|
+
``semdiff.normalize()`` is L1 on its own: parse (T-10) → the phase-ordered engine over the
|
|
4
|
+
built-in families (T-12..T-17) → canonical serialization (D-032). It is deterministic — the
|
|
5
|
+
same input and config always produce the same string — and idempotent, because the
|
|
6
|
+
canonicalization phase runs last and reaches a fixpoint.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from semdiff.config import Config
|
|
12
|
+
from semdiff.normalize.registry import BUILTIN_RULES, apply_rules
|
|
13
|
+
from semdiff.parse import parse
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def normalize(html: bytes | str, config: Config | None = None, *, encoding: str | None = None) -> str:
|
|
17
|
+
"""Return ``html`` with presentational noise removed and the tree canonicalized.
|
|
18
|
+
|
|
19
|
+
``bytes`` are decoded as T-10 decides (declared ``encoding`` → BOM → ``<meta charset>``
|
|
20
|
+
→ UTF-8); the result is always ``str``. Raises ``InputTooLargeError`` (NFR-7),
|
|
21
|
+
``ParseError`` (FR-3), or ``ValueError`` if the config toggles an unknown rule id.
|
|
22
|
+
"""
|
|
23
|
+
config = config or Config()
|
|
24
|
+
doc = parse(html, encoding=encoding, config=config)
|
|
25
|
+
return apply_rules(doc, BUILTIN_RULES, config.normalization).tree.html or ""
|