From 8261ef36b577fa91b0e264c4eb4d4afb8ca1b068 Mon Sep 17 00:00:00 2001 From: Sanket17052006 Date: Thu, 21 May 2026 00:08:45 +0530 Subject: [PATCH 1/5] refactor: Convert AffiliateLinks.html.jinja to Jinja --- openlibrary/macros/AffiliateLinks.html.jinja | 33 ++ openlibrary/plugins/openlibrary/partials.py | 67 +++- .../tests/test_jinja_experiment.py | 327 ++++++++++++++++++ requirements.txt | 1 + 4 files changed, 426 insertions(+), 2 deletions(-) create mode 100644 openlibrary/macros/AffiliateLinks.html.jinja create mode 100644 openlibrary/plugins/openlibrary/tests/test_jinja_experiment.py diff --git a/openlibrary/macros/AffiliateLinks.html.jinja b/openlibrary/macros/AffiliateLinks.html.jinja new file mode 100644 index 00000000000..bf780a9e45b --- /dev/null +++ b/openlibrary/macros/AffiliateLinks.html.jinja @@ -0,0 +1,33 @@ +{% macro affiliate_link(store) %} +
  • + {{ store.name }} + {% if store.price %} +
    + {{ store.price }}{{ store.price_note }} + {% endif %} +
  • +{% endmacro %} + + + + {{ _('When you buy books using these links the Internet Archive may earn a small commission.') | safe }} + diff --git a/openlibrary/plugins/openlibrary/partials.py b/openlibrary/plugins/openlibrary/partials.py index 9a5fd963a7e..dc2f0b51920 100644 --- a/openlibrary/plugins/openlibrary/partials.py +++ b/openlibrary/plugins/openlibrary/partials.py @@ -1,9 +1,15 @@ +import logging +import os +import re +import time as _time from dataclasses import dataclass from hashlib import md5 +from pathlib import Path from typing import Literal, NotRequired, TypedDict from urllib.parse import parse_qs, quote, quote_plus import web +from jinja2 import Environment, FileSystemLoader from pydantic import BaseModel from infogami.utils.view import public, render_template @@ -19,7 +25,6 @@ get_betterworldbooks_metadata, ) from openlibrary.i18n import gettext as _ -from openlibrary.plugins.openlibrary.code import is_bot from openlibrary.plugins.openlibrary.lists import get_lists_async, get_user_lists from openlibrary.plugins.upstream.utils import render_macro from openlibrary.plugins.upstream.yearly_reading_goals import get_reading_goals @@ -292,6 +297,38 @@ def build_more_stores(ctx: AffiliateStoreBuildContext) -> list[AffiliateStore]: ] +_JINJA_ENV = Environment( + loader=FileSystemLoader( + Path(__file__).resolve().parent.parent.parent / "macros" + ), + autoescape=True, +) +_JINJA_ENV.globals["_"] = _ + +logger = logging.getLogger("openlibrary.partials") +_EXPERIMENT_LOGGER = logging.getLogger("openlibrary.jinja_experiment") +_JINJA_EXPERIMENT_ENABLED = os.getenv("OL_JINJA_EXPERIMENT") == "1" + + +def _normalize_html(html: str) -> str: + """Collapse all non-semantic whitespace in HTML for comparison between engines.""" + html = re.sub(r">\s+<", "><", html) + html = re.sub(r"\s{2,}", " ", html) + return html.strip() + + +def _render_affiliate_links_jinja( + primary_stores: list[AffiliateStore], + more_stores: list[AffiliateStore], +) -> str: + """Render affiliate links using Jinja2 for experiment comparison.""" + template = _JINJA_ENV.get_template("AffiliateLinks.html.jinja") + return template.render( + primary_stores=primary_stores, + more_stores=more_stores, + ) + + class AffiliateLinksPartial: """Handler for affiliate links""" @@ -302,6 +339,8 @@ async def generate_async( asin: str | None, prices: bool, ) -> dict: + from openlibrary.plugins.openlibrary.code import is_bot + bwb_metadata = None amz_metadata = None should_fetch_prices = not is_bot() and prices @@ -317,8 +356,32 @@ async def generate_async( primary_stores = build_primary_stores(ctx) more_stores = build_more_stores(ctx) + + t0 = _time.perf_counter() macro = web.template.Template.globals["macros"].AffiliateLinks(primary_stores, more_stores) - return {"partials": str(macro)} + templetor_html = str(macro) + t1 = _time.perf_counter() + + if _JINJA_EXPERIMENT_ENABLED: + t2 = _time.perf_counter() + try: + jinja2_html = _render_affiliate_links_jinja(primary_stores, more_stores) + t3 = _time.perf_counter() + match = _normalize_html(templetor_html) == _normalize_html(jinja2_html) + _EXPERIMENT_LOGGER.info( + "Jinja2 experiment | match=%s templetor_ms=%.2f jinja2_ms=%.2f | title=%s isbn=%s", + match, + (t1 - t0) * 1000, + (t3 - t2) * 1000, + title, + isbn, + ) + except Exception: + _EXPERIMENT_LOGGER.exception( + "Jinja2 experiment failed | title=%s isbn=%s", title, isbn, + ) + + return {"partials": templetor_html} class SearchFacetsPartial: diff --git a/openlibrary/plugins/openlibrary/tests/test_jinja_experiment.py b/openlibrary/plugins/openlibrary/tests/test_jinja_experiment.py new file mode 100644 index 00000000000..62399731f17 --- /dev/null +++ b/openlibrary/plugins/openlibrary/tests/test_jinja_experiment.py @@ -0,0 +1,327 @@ +"""Tests for the Phase 3 Jinja2 template experiment. + +Validates that the Jinja2 AffiliateLinks template renders correctly +and produces semantically equivalent HTML to the Templetor template. + +Uses Jinja2 directly (not via partials.py) to avoid web.py dependency +which is unavailable on Python 3.14+. +""" + +from dataclasses import dataclass +from pathlib import Path + +import pytest + +JINJA2_AVAILABLE = False +try: + import jinja2 # noqa: F401 +except ImportError: + pass +else: + JINJA2_AVAILABLE = True + +WEBPY_AVAILABLE = False +WEBPY_GETTEXT = None +try: + import web + + WEBPY_AVAILABLE = True +except ImportError: + pass + +if WEBPY_AVAILABLE: + def _gettext_stub(message, *args, **kwargs): + if kwargs: + return message % kwargs + return message + + WEBPY_GETTEXT = _gettext_stub + + +PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent.parent +MACROS_DIR = PROJECT_ROOT / "macros" + + +def _make_jinja_env(): + """Create a Jinja2 environment configured like the production experiment.""" + from jinja2 import Environment, FileSystemLoader + + env = Environment( + loader=FileSystemLoader(str(MACROS_DIR)), + autoescape=True, + ) + + def fake_gettext(message: str, **kwargs) -> str: + if kwargs: + return message % kwargs + return message + + env.globals["_"] = fake_gettext + return env + + +def _normalize_html(html: str) -> str: + """Collapse all non-semantic whitespace in HTML for comparison between engines.""" + import re + + html = re.sub(r">\s+<", "><", html) + html = re.sub(r"\s{2,}", " ", html) + return html.strip() + + +@pytest.fixture(scope="module") +def jinja_env(): + return _make_jinja_env() + + +@pytest.fixture(scope="module") +def affiliate_links_template(jinja_env): + return jinja_env.get_template("AffiliateLinks.html.jinja") + + +# --- Test data objects --- + + +@dataclass +class _AffiliateStore: + key: str + analytics_key: str + name: str + link: str + price: str | None = None + price_note: str = "" + + +# --- _normalize_html tests --- + + +class TestNormalizeHtml: + def test_collapses_whitespace_between_tags(self): + assert _normalize_html("
    a
    ") == "
    a
    " + + def test_preserves_inner_text_whitespace(self): + assert _normalize_html("hello world") == "hello world" + + def test_empty_string(self): + assert _normalize_html("") == "" + + def test_strips_outer_whitespace(self): + assert _normalize_html("
    text
    ") == "
    text
    " + + +# --- Jinja2 rendering tests --- + + +class TestJinja2TemplateRendering: + """Tests that the Jinja2 template renders without errors and produces expected output.""" + + def test_renders_empty_stores(self, affiliate_links_template): + result = affiliate_links_template.render(primary_stores=[], more_stores=[]) + assert '' in result + assert '