diff --git a/pyproject.toml b/pyproject.toml index 82303986..082982da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -66,6 +66,7 @@ test = [ "pytest-env==1.1.5", "vcrpy==7.0.0; python_version >='3.10'", "mock==5.2.0", + "responses==0.25.8", ] dev = [ {include-group = "test"}, diff --git a/ricecooker/utils/downloader.py b/ricecooker/utils/downloader.py index 859ff26e..df553d8a 100644 --- a/ricecooker/utils/downloader.py +++ b/ricecooker/utils/downloader.py @@ -390,8 +390,12 @@ def download_assets( # if we're really stuck, just default to HTML as that is most likely if this is a redirect. if not ext: ext = ".html" - subpath = os.path.dirname(filename) - filename = "index{}".format(ext) + + subpath = filename + # Add the existing filename in front of index.xxx, this can contain slashes and those will result + # in subdirectories created in the downloaded version. This ensures multiple instances of extensionless + # resources referenced from a page won't clobber each other. + filename = filename + "/index{}".format(ext) os.makedirs(os.path.join(destination, subpath), exist_ok=True) @@ -429,7 +433,7 @@ def js_content_middleware(content, url, **kwargs): return content def css_node_filter(node): - if "rel" in node: + if "rel" in node.attrs: return "stylesheet" in node["rel"] return node["href"].split("?")[0].strip().endswith(".css") diff --git a/tests/test_downloader.py b/tests/test_downloader.py index 911e08a6..a306a544 100644 --- a/tests/test_downloader.py +++ b/tests/test_downloader.py @@ -1,8 +1,49 @@ +import mimetypes import os +import re +import tempfile import unittest +from pathlib import Path +from urllib.parse import unquote +from urllib.parse import urlparse + +import responses from ricecooker.utils import downloader +TESTCONTENT = Path(__file__).resolve().parent / "testcontent" + +# Portless host used to serve the checked-in sample tree. A real host (no +# ":port") keeps the archived paths free of colons, which are illegal in +# Windows filenames - the reason the previous embedded-server version of this +# test failed on Windows. +SAMPLE_HOST = "https://example.org" + + +def _serve_sample_tree(request): + """responses callback: serve files from tests/testcontent as a static site. + + Maps the request URL path onto the on-disk sample tree so the fixtures + checked in for this test are served verbatim, no local web server needed. + """ + rel_path = unquote(urlparse(request.url).path).lstrip("/") + file_path = TESTCONTENT / rel_path + if not file_path.is_file(): + return (404, {}, b"") + ext = file_path.suffix + if ext == ".html": + content_type = "text/html; charset=utf-8" + elif ext == "": + # Extensionless resources in this fixture are stylesheets; Google Fonts + # serves its CSS from an extensionless URL, which is what this test + # exercises. + content_type = "text/css" + else: + content_type = ( + mimetypes.guess_type(str(file_path))[0] or "application/octet-stream" + ) + return (200, {"Content-Type": content_type}, file_path.read_bytes()) + class TestArchiver(unittest.TestCase): def test_get_archive_filename_absolute(self): @@ -70,3 +111,63 @@ def test_archive_path_as_relative_url(self): link_filename, page_filename ) assert rel_path == "../kolibri_1.2.3.png" + + +# The sample tree lives under a subject folder mirroring a real PreTeXt book: +# activecalculus.org (the page) references a stylesheet from fonts.googleapis.com +# via an extensionless URL, whose CSS in turn references a font on +# fonts.gstatic.com. +SAMPLE_ROOT = "samples/PreTeXt_book_test_manually_cleaned_urls" +# The Google Fonts stylesheet URL is extensionless; the original had a colon, +# replaced with an underscore so the fixture file checks out on Windows. +CSS_URL_PART = "css2_family_Material+Symbols+Outlined_opsz,wght,FILL,GRAD@24,400,0,0" + + +@responses.activate +def test_pretextbook_css_fetch(): + """A stylesheet linked via an extensionless URL is archived and rewritten. + + Regression test for two bugs when archiving a PreTeXt book: the CSS + ```` (matched by ``rel="stylesheet"``, not a ``.css`` extension) was + skipped, and the generated ``index.css`` for the extensionless resource was + dumped at the archive root instead of nested under the resource's own path, + which made its relative font references escape the archive directory. + """ + responses.add_callback( + responses.GET, + re.compile(re.escape(SAMPLE_HOST) + r"/.*"), + callback=_serve_sample_tree, + ) + + page_url = "{}/{}/activecalculus.org/single2e/sec-5-2-FTC2.html".format( + SAMPLE_HOST, SAMPLE_ROOT + ) + + with tempfile.TemporaryDirectory() as download_root: + archive = downloader.ArchiveDownloader(download_root) + archive.get_page(page_url) + + book_dest_dir = Path(download_root) / "example.org" / SAMPLE_ROOT + + # The page's stylesheet link is rewritten to the nested index.css. + page_html = ( + book_dest_dir / "activecalculus.org" / "single2e" / "sec-5-2-FTC2.html" + ).read_text() + assert "../../fonts.googleapis.com/" + CSS_URL_PART + "/index.css" in page_html + + # The extensionless stylesheet is nested under its own path as index.css, + # not clobbered at the archive root. + index_css = book_dest_dir / "fonts.googleapis.com" / CSS_URL_PART / "index.css" + css_contents = index_css.read_text() + assert "fonts.gstatic.com/s/materialsymbolsoutlined" in css_contents + + # The font referenced from the CSS is downloaded to its own nested path. + font_path = ( + book_dest_dir + / "fonts.gstatic.com" + / "s" + / "materialsymbolsoutlined" + / "v290" + / "material_symbols.woff" + ) + assert font_path.stat().st_size > 0 diff --git a/tests/testcontent/samples/PreTeXt_book_test_manually_cleaned_urls/activecalculus.org/single2e/sec-5-2-FTC2.html b/tests/testcontent/samples/PreTeXt_book_test_manually_cleaned_urls/activecalculus.org/single2e/sec-5-2-FTC2.html new file mode 100644 index 00000000..4d2a6fac --- /dev/null +++ b/tests/testcontent/samples/PreTeXt_book_test_manually_cleaned_urls/activecalculus.org/single2e/sec-5-2-FTC2.html @@ -0,0 +1,22 @@ + + + + + + + + + + + AC The Second Fundamental Theorem of Calculus + + + + + + + + + diff --git a/tests/testcontent/samples/PreTeXt_book_test_manually_cleaned_urls/fonts.googleapis.com/css2_family_Material+Symbols+Outlined_opsz,wght,FILL,GRAD@24,400,0,0 b/tests/testcontent/samples/PreTeXt_book_test_manually_cleaned_urls/fonts.googleapis.com/css2_family_Material+Symbols+Outlined_opsz,wght,FILL,GRAD@24,400,0,0 new file mode 100644 index 00000000..00d15183 --- /dev/null +++ b/tests/testcontent/samples/PreTeXt_book_test_manually_cleaned_urls/fonts.googleapis.com/css2_family_Material+Symbols+Outlined_opsz,wght,FILL,GRAD@24,400,0,0 @@ -0,0 +1,21 @@ +@font-face { + font-family: 'Material Symbols Outlined'; + font-style: normal; + font-weight: 400; + src: url("../fonts.gstatic.com/s/materialsymbolsoutlined/v290/material_symbols.woff") format('woff'); +} + +.material-symbols-outlined { + font-family: 'Material Symbols Outlined'; + font-weight: normal; + font-style: normal; + font-size: 24px; + line-height: 1; + letter-spacing: normal; + text-transform: none; + display: inline-block; + white-space: nowrap; + word-wrap: normal; + direction: ltr; + -moz-font-feature-settings: 'liga'; +} diff --git a/tests/testcontent/samples/PreTeXt_book_test_manually_cleaned_urls/fonts.gstatic.com/s/materialsymbolsoutlined/v290/material_symbols.woff b/tests/testcontent/samples/PreTeXt_book_test_manually_cleaned_urls/fonts.gstatic.com/s/materialsymbolsoutlined/v290/material_symbols.woff new file mode 100644 index 00000000..025a3daa --- /dev/null +++ b/tests/testcontent/samples/PreTeXt_book_test_manually_cleaned_urls/fonts.gstatic.com/s/materialsymbolsoutlined/v290/material_symbols.woff @@ -0,0 +1 @@ +Not really a font, just a placeholder file. \ No newline at end of file diff --git a/uv.lock b/uv.lock index a19a1a99..3f4dbb54 100644 --- a/uv.lock +++ b/uv.lock @@ -1459,6 +1459,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e1/d5/de8f089119205a09da657ed4784c584ede8381a0ce6821212a6d4ca47054/requests_file-3.0.1-py2.py3-none-any.whl", hash = "sha256:d0f5eb94353986d998f80ac63c7f146a307728be051d4d1cd390dbdb59c10fa2", size = 4514, upload-time = "2025-10-20T18:56:41.184Z" }, ] +[[package]] +name = "responses" +version = "0.25.8" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml", marker = "platform_python_implementation == 'CPython'" }, + { name = "requests", marker = "platform_python_implementation == 'CPython'" }, + { name = "urllib3", marker = "platform_python_implementation == 'CPython'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/95/89c054ad70bfef6da605338b009b2e283485835351a9935c7bfbfaca7ffc/responses-0.25.8.tar.gz", hash = "sha256:9374d047a575c8f781b94454db5cab590b6029505f488d12899ddb10a4af1cf4", size = 79320, upload-time = "2025-08-08T19:01:46.709Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1c/4c/cc276ce57e572c102d9542d383b2cfd551276581dc60004cb94fe8774c11/responses-0.25.8-py3-none-any.whl", hash = "sha256:0c710af92def29c8352ceadff0c3fe340ace27cf5af1bbe46fb71275bcd2831c", size = 34769, upload-time = "2025-08-08T19:01:45.018Z" }, +] + [[package]] name = "ricecooker" source = { editable = "." } @@ -1503,6 +1517,7 @@ dev = [ { name = "pytest", marker = "platform_python_implementation == 'CPython'" }, { name = "pytest-env", marker = "platform_python_implementation == 'CPython'" }, { name = "requests-cache", marker = "platform_python_implementation == 'CPython'" }, + { name = "responses", marker = "platform_python_implementation == 'CPython'" }, { name = "ruff", marker = "platform_python_implementation == 'CPython'" }, { name = "vcrpy", marker = "python_full_version >= '3.10' and platform_python_implementation == 'CPython'" }, ] @@ -1511,6 +1526,7 @@ test = [ { name = "pytest", marker = "platform_python_implementation == 'CPython'" }, { name = "pytest-env", marker = "platform_python_implementation == 'CPython'" }, { name = "requests-cache", marker = "platform_python_implementation == 'CPython'" }, + { name = "responses", marker = "platform_python_implementation == 'CPython'" }, { name = "vcrpy", marker = "python_full_version >= '3.10' and platform_python_implementation == 'CPython'" }, ] @@ -1550,6 +1566,7 @@ dev = [ { name = "pytest", specifier = "==8.4.2" }, { name = "pytest-env", specifier = "==1.1.5" }, { name = "requests-cache", specifier = "==1.2.1" }, + { name = "responses", specifier = "==0.25.8" }, { name = "ruff", specifier = ">=0.11" }, { name = "vcrpy", marker = "python_full_version >= '3.10'", specifier = "==7.0.0" }, ] @@ -1558,6 +1575,7 @@ test = [ { name = "pytest", specifier = "==8.4.2" }, { name = "pytest-env", specifier = "==1.1.5" }, { name = "requests-cache", specifier = "==1.2.1" }, + { name = "responses", specifier = "==0.25.8" }, { name = "vcrpy", marker = "python_full_version >= '3.10'", specifier = "==7.0.0" }, ]