Skip to content

Commit 04dd959

Browse files
committed
File Tree Diff: hash the content of a page, not its HTML
Attributes rendered into the HTML change from build to build without the page changing, so pages nobody touched were reported as modified. Hash the text of the main node and its link and image targets instead, and version the manifest so manifests built with different algorithms aren't compared.
1 parent e785797 commit 04dd959

19 files changed

Lines changed: 149 additions & 24 deletions

readthedocs/filetreediff/__init__.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,17 @@ def get_diff(current_version: Version, base_version: Version) -> FileTreeDiff |
8585
if base_latest_build.id != base_version_manifest.build.id:
8686
outdated = True
8787

88+
if current_version_manifest.hash_version != base_version_manifest.hash_version:
89+
# Manifests generated with different versions of the hashing algorithm
90+
# can't be compared, every file would show up as modified.
91+
# This resolves itself once both versions have been built again.
92+
log.info(
93+
"Skipping file tree diff, manifests use different hash versions.",
94+
current_version_hash_version=current_version_manifest.hash_version,
95+
base_version_hash_version=base_version_manifest.hash_version,
96+
)
97+
return None
98+
8899
current_version_file_paths = set(current_version_manifest.files.keys())
89100
base_version_file_paths = set(base_version_manifest.files.keys())
90101

readthedocs/filetreediff/dataclasses.py

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,13 @@
1111
from readthedocs.core.resolver import Resolver
1212

1313

14+
# Version of the algorithm used to generate the ``main_content_hash`` of each file.
15+
# Bump it when the way we hash the content of a page changes, so manifests
16+
# generated with different versions of the algorithm aren't compared to each
17+
# other (every file would show up as modified).
18+
MAIN_CONTENT_HASH_VERSION = 1
19+
20+
1421
@dataclass(slots=True)
1522
class FileTreeDiffBuild:
1623
"""The build associated with a file tree manifest."""
@@ -32,10 +39,17 @@ class FileTreeDiffManifest:
3239

3340
files: dict[str, FileTreeDiffManifestFile]
3441
build: FileTreeDiffBuild
42+
hash_version: int
3543

36-
def __init__(self, build_id: int, files: list[FileTreeDiffManifestFile]):
44+
def __init__(
45+
self,
46+
build_id: int,
47+
files: list[FileTreeDiffManifestFile],
48+
hash_version: int = MAIN_CONTENT_HASH_VERSION,
49+
):
3750
self.build = FileTreeDiffBuild(id=build_id)
3851
self.files = {file.path: file for file in files}
52+
self.hash_version = hash_version
3953

4054
@classmethod
4155
def from_dict(cls, data: dict) -> "FileTreeDiffManifest":
@@ -46,11 +60,14 @@ def from_dict(cls, data: dict) -> "FileTreeDiffManifest":
4660
converting the object to a dictionary using the `as_dict` method.
4761
"""
4862
build_id = data["build"]["id"]
63+
# Manifests generated before the hash version was introduced don't have
64+
# the field, they all used the same (first) version of the algorithm.
65+
hash_version = data.get("hash_version", 0)
4966
files = [
5067
FileTreeDiffManifestFile(path=path, main_content_hash=file["main_content_hash"])
5168
for path, file in data["files"].items()
5269
]
53-
return cls(build_id, files)
70+
return cls(build_id, files, hash_version)
5471

5572
def as_dict(self) -> dict:
5673
"""Convert the object to a dictionary."""

readthedocs/filetreediff/tests/test_filetreediff.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from readthedocs.builds.constants import BUILD_STATE_FINISHED, EXTERNAL, LATEST
99
from readthedocs.builds.models import Build, Version
1010
from readthedocs.filetreediff import get_diff, snapshot_base_manifest
11+
from readthedocs.filetreediff.dataclasses import MAIN_CONTENT_HASH_VERSION
1112
from readthedocs.projects.models import Project
1213
from readthedocs.rtd_tests.storage import BuildMediaFileSystemStorageTest
1314

@@ -22,11 +23,16 @@ def f(*args, **kwargs):
2223
return f
2324

2425

25-
def _mock_manifest(build_id: int, files: dict[str, str]):
26+
def _mock_manifest(
27+
build_id: int,
28+
files: dict[str, str],
29+
hash_version: int = MAIN_CONTENT_HASH_VERSION,
30+
):
2631
return _mock_open(
2732
json.dumps(
2833
{
2934
"build": {"id": build_id},
35+
"hash_version": hash_version,
3036
"files": {
3137
path: {"main_content_hash": content_hash}
3238
for path, content_hash in files.items()
@@ -122,6 +128,18 @@ def test_diff_changes(self, storage_open):
122128
assert [file.path for file in diff.modified] == ["tutorials/index.html"]
123129
assert not diff.outdated
124130

131+
@mock.patch.object(BuildMediaFileSystemStorageTest, "open")
132+
def test_diff_manifests_with_different_hash_versions(self, storage_open):
133+
files = {
134+
"index.html": "hash1",
135+
"tutorials/index.html": "hash2",
136+
}
137+
storage_open.side_effect = [
138+
_mock_manifest(self.build_a.id, files, hash_version=2)(),
139+
_mock_manifest(self.build_b.id, files, hash_version=1)(),
140+
]
141+
assert get_diff(self.version_a, self.version_b) is None
142+
125143
@mock.patch.object(BuildMediaFileSystemStorageTest, "open")
126144
def test_missing_manifest(self, storage_open):
127145
storage_open.side_effect = FileNotFoundError

readthedocs/search/parsers.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,27 @@ def _clean_body(self, body):
337337

338338
return body
339339

340+
def _get_main_content_hash(self, body):
341+
"""
342+
Get a hash of the main content of the page.
343+
344+
The hash is used to detect if a page has changed between two builds
345+
(see the ``readthedocs.filetreediff`` module), so it's built from the
346+
parts of the page that a reader would notice: its text, and the targets
347+
of its links and images.
348+
349+
The HTML itself isn't hashed, as its attributes hold values that change
350+
from one build to the next without the page changing at all. The version
351+
of the project an intersphinx link points to is one example, it's
352+
rendered into the ``title`` attribute of every external reference.
353+
"""
354+
content = [" ".join(body.text(separator=" ").split())]
355+
for node in body.css("[href]"):
356+
content.append("href=" + (node.attributes.get("href") or ""))
357+
for node in body.css("[src]"):
358+
content.append("src=" + (node.attributes.get("src") or ""))
359+
return hashlib.md5("\n".join(content).encode()).hexdigest()
360+
340361
def _is_section(self, tag):
341362
"""
342363
Check if `tag` is a section (linkeable header).
@@ -479,8 +500,8 @@ def _process_content(self, page, content):
479500
sections = []
480501
main_content_hash = None
481502
if body:
482-
main_content_hash = hashlib.md5(body.html.encode()).hexdigest()
483503
body = self._clean_body(body)
504+
main_content_hash = self._get_main_content_hash(body)
484505
title = self._get_page_title(body, html) or page
485506
sections = self._get_sections(title=title, body=body)
486507
else:

readthedocs/search/tests/data/generic/out/basic.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
{
33
"path": "basic.html",
44
"title": "Title of the page",
5-
"main_content_hash": "006cb661925c211be260be17d64662b5",
5+
"main_content_hash": "ae7bd4075a928bf3d01110ef074536df",
66
"sections": [
77
{
88
"id": "love",

readthedocs/search/tests/data/mkdocs/out/gitbook.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
{
33
"path": "index.html",
44
"title": "Mkdocs - GitBook Theme",
5-
"main_content_hash": "a2050380ef001ef57754fb35b41d477d",
5+
"main_content_hash": "0e7044baeb4e81314d7ca7dcc3520ccc",
66
"sections": [
77
{
88
"id": "mkdocs-gitbook-theme",

readthedocs/search/tests/data/mkdocs/out/material.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
{
33
"path": "index.html",
44
"title": "Overview",
5-
"main_content_hash": "448eb15bfef60bc49a4059a34ebeaa4b",
5+
"main_content_hash": "8e0837bd6812a5ad4f4ca0a8edc7bd37",
66
"sections": [
77
{
88
"id": "",

readthedocs/search/tests/data/mkdocs/out/mkdocs-1.1.json

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
{
33
"path": "index.html",
44
"title": "MkDocs",
5-
"main_content_hash": "aca1f625e559fe49718abe801532b3ef",
5+
"main_content_hash": "0c0974edad01cd3beaa61c0046f34b14",
66
"sections": [
77
{
88
"id": "mkdocs",
@@ -39,7 +39,7 @@
3939
{
4040
"path": "404.html",
4141
"title": "404",
42-
"main_content_hash": "2f582057ff73758c152d6925eb149099",
42+
"main_content_hash": "7f5c24be74faf261f5aa35b567b838bf",
4343
"sections": [
4444
{
4545
"id": "404-page-not-found",
@@ -51,7 +51,7 @@
5151
{
5252
"path": "configuration.html",
5353
"title": "Configuration",
54-
"main_content_hash": "6a5526f41cfef3da4c541f0465ac42e8",
54+
"main_content_hash": "da702fc5778f6e9198781964bc734ab5",
5555
"sections": [
5656
{
5757
"id": "configuration",
@@ -93,7 +93,7 @@
9393
{
9494
"path": "no-title.html",
9595
"title": "No title - Read the Docs MkDocs Test",
96-
"main_content_hash": "c02e4800b9da5e96ed35841f11072777",
96+
"main_content_hash": "a7a8e577ce57e09847174516c22bffbf",
9797
"sections": [
9898
{
9999
"id": "",
@@ -105,7 +105,7 @@
105105
{
106106
"path": "no-main-header.html",
107107
"title": "I'm the header",
108-
"main_content_hash": "43010c91ae89c036b157c2a3947d63b8",
108+
"main_content_hash": "1bda704ab824e1a72c9d5b60dfc21090",
109109
"sections": [
110110
{
111111
"id": "",

readthedocs/search/tests/data/mkdocs/out/readthedocs-1.1.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
{
33
"path": "index.html",
44
"title": "Read the Docs MkDocs Test Project",
5-
"main_content_hash": "25c48df9b31b31eaee235206a48239a5",
5+
"main_content_hash": "eba9d67d8863ea87e36e9ecd88156a71",
66
"sections": [
77
{
88
"id": "read-the-docs-mkdocs-test-project",
@@ -29,7 +29,7 @@
2929
{
3030
"path": "404.html",
3131
"title": "404",
32-
"main_content_hash": "5f6d687421dad7e1409dcaa3ccd2d34d",
32+
"main_content_hash": "7f5c24be74faf261f5aa35b567b838bf",
3333
"sections": [
3434
{
3535
"id": "404-page-not-found",
@@ -41,7 +41,7 @@
4141
{
4242
"path": "versions.html",
4343
"title": "Versions & Themes",
44-
"main_content_hash": "68a6609034e0a45adb135a60b98c12b8",
44+
"main_content_hash": "8b5245086e11b33a5a52709e4a804ff6",
4545
"sections": [
4646
{
4747
"id": "versions-themes",

readthedocs/search/tests/data/mkdocs/out/windmill.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
{
33
"path": "index.html",
44
"title": "Windmill theme",
5-
"main_content_hash": "68542e8759d648c4304f0e1ca85a0547",
5+
"main_content_hash": "0036900dcea4d39c40e4829c069f56ed",
66
"sections": [
77
{
88
"id": "windmill-theme",

0 commit comments

Comments
 (0)