From f4cffb721538b6a49aae71fa219654b4d9c2d2cc Mon Sep 17 00:00:00 2001 From: WEN Hao Date: Thu, 29 Oct 2026 10:31:55 +0800 Subject: [PATCH 1/3] feat(app): persistent results and multi-slot cache in the Streamlit app - keep the rendered result in st.session_state["last_render"] instead of writing widgets inside the lookup callback: results now survive plain script re-runs (e.g. websocket reconnects), and clearing the input cleanly removes the result - cache lookups per (identifier, arxiv2doi, format) instead of a single slot: switching between formats of the same identifier no longer re-queries the network, and cached bibtex entries are re-rendered locally when alignment / capitalize_title change - do_lookup() is now a pure session-state writer and rendering happens unconditionally from the stored state; the confirm-flag dance with the divider is gone - add AppTest-based regression tests (test/test_streamlit_app.py) covering enter-to-search, sidebar re-renders without network, format round trips, persistence across re-runs, and the redirect / error / exception paths --- CHANGELOG.rst | 9 +++ streamlit_app.py | 148 +++++++++++++++++++++---------------- test/test_streamlit_app.py | 131 ++++++++++++++++++++++++++++++++ 3 files changed, 223 insertions(+), 65 deletions(-) create mode 100644 test/test_streamlit_app.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 3229e03..093af85 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,6 +11,15 @@ Versioning `__. `Unreleased `__ ---------------------------------------------------------------------------- +Added +~~~~~ +- The Streamlit app now keeps the rendered result in the session state, so it + survives script re-runs (e.g. websocket reconnects), and caches lookups per + ``(identifier, arxiv2doi, format)``: switching between formats of the same + identifier or re-rendering with a different alignment / title case no longer + triggers a new network request. Covered by new AppTest-based tests in + ``test/test_streamlit_app.py``. + `0.2.0 `__ - 2026-10-28 -------------------------------------------------------------------------------------- diff --git a/streamlit_app.py b/streamlit_app.py index 5c33ea6..269ed54 100644 --- a/streamlit_app.py +++ b/streamlit_app.py @@ -14,64 +14,73 @@ def get_bib_lookuper(): return BibLookup(verbose=0) +# the marker that follows the resolvable URL in BibLookup's redirect message +REDIRECT_URL_MARKER = "visit: " + + +def current_config(): + return { + "doi": st.session_state.get("input_doi", "").strip(), + "arxiv2doi": st.session_state.get("arxiv2doi", True), + "align": st.session_state.get("align", "middle"), + "fmt": st.session_state.get("fmt", "bibtex"), + "capitalize_title": st.session_state.get("capitalize_title", False), + } + + +def cache_key(cfg): + return f"{cfg['doi']}||{cfg['arxiv2doi']}||{cfg['fmt']}" + + +def render_success(container, text: str) -> None: + container.code(text, language="latex", line_numbers=False) + + +def render_redirect(container, url: str) -> None: + container.warning("⚠️ This DOI cannot be resolved to BibTeX automatically (ChinaDOI/CNKI).") + container.link_button("👉 Click to visit Article Page", url) + + def do_lookup(): - if input_doi == "": - pass # do nothing - else: - current_arxiv2doi = st.session_state.arxiv2doi if "arxiv2doi" in st.session_state else True - current_align = st.session_state.align if "align" in st.session_state else "middle" - current_fmt = st.session_state.fmt if "fmt" in st.session_state else "bibtex" - current_capitalize_title = st.session_state.capitalize_title if "capitalize_title" in st.session_state else False - if "history" in st.session_state and st.session_state["history"] != {}: - if ( - st.session_state["history"]["doi"] == input_doi - and st.session_state["history"]["arxiv2doi"] == current_arxiv2doi - ): - cached_bib = st.session_state["history"]["bib"] - if isinstance(cached_bib, str) and cached_bib.startswith(BibLookup.__REDIRECT_FLAG__): - url = cached_bib.split("visit: ")[-1].strip() - output_container.warning("⚠️ This DOI cannot be resolved to BibTeX automatically (ChinaDOI/CNKI).") - output_container.link_button("👉 Click to visit Article Page", url) - return - if st.session_state["history"]["fmt"] == current_fmt == "bibtex": - bib = str(bl._to_bib_item(cached_bib, align=current_align, capitalize_title=current_capitalize_title)) - output_container.code(bib, language="latex", line_numbers=False) - return - elif st.session_state["history"]["fmt"] == current_fmt: - output_container.code(cached_bib, language="latex", line_numbers=False) - return - try: - bib = bl( - input_doi, - arxiv2doi=current_arxiv2doi, - format=current_fmt, - align=current_align, - capitalize_title=current_capitalize_title, - ) - # cache into session_state["history"] - if "history" not in st.session_state: - st.session_state["history"] = {} - if bib not in bl.lookup_errors: - st.session_state["history"]["doi"] = input_doi - st.session_state["history"]["fmt"] = current_fmt - st.session_state["history"]["arxiv2doi"] = current_arxiv2doi - st.session_state["history"]["bib"] = bib - st.session_state["history"]["capitalize_title"] = current_capitalize_title - except Exception as e: - output_container.error(f"Error: {e}") + """Perform (or re-render) the lookup and store the result in the session state. + + The result is kept in ``st.session_state["last_render"]`` instead of being + written to widgets directly, so it survives script re-runs (e.g. websocket + reconnects) and is re-displayed without another lookup. + """ + cfg = current_config() + render = None + if cfg["doi"]: + history = st.session_state.setdefault("history", {}) + slot = history.get(cache_key(cfg)) + if slot is not None: + # cache hit: no network call; a cached bibtex entry can be + # re-rendered locally with the current align / capitalize settings + if cfg["fmt"] == "bibtex": + bib = str(bl._to_bib_item(slot, align=cfg["align"], capitalize_title=cfg["capitalize_title"])) + else: + bib = slot + render = {"status": "success", "text": bib} else: - if bib and bib.startswith(BibLookup.__REDIRECT_FLAG__): - url = bib.split("visit: ")[-1].strip() - output_container.warning( - "Automatic lookup failed. This appears to be a ChinaDOI/CNKI record that does not support standard BibTeX export." + try: + bib = bl( + cfg["doi"], + arxiv2doi=cfg["arxiv2doi"], + format=cfg["fmt"], + align=cfg["align"], + capitalize_title=cfg["capitalize_title"], ) - output_container.markdown(f"**Redirect URL detected:** `{url}`") - output_container.link_button("👉 Click to visit Article Page", url) - elif bib in bl.lookup_errors: - output_container.error(f"Lookup Failed: {bib}") + except Exception as e: + render = {"status": "error", "text": f"Error: {e}"} else: - output_container.code(bib, language="latex", line_numbers=False) - + if bib and bib.startswith(BibLookup.__REDIRECT_FLAG__): + render = {"status": "redirect", "url": bib.split(REDIRECT_URL_MARKER)[-1].strip()} + elif bib in bl.lookup_errors: + render = {"status": "error", "text": f"Lookup Failed: {bib}"} + else: + history[cache_key(cfg)] = bib + render = {"status": "success", "text": bib} + st.session_state["last_render"] = render st.session_state["confirmed_doi"] = False @@ -87,7 +96,7 @@ def do_lookup(): st.sidebar.title("Configurations") # select the style of alignment -align = st.sidebar.selectbox( +st.sidebar.selectbox( label="Alignment", options=["middle", "left", "left-middle"], index=0, @@ -95,7 +104,7 @@ def do_lookup(): on_change=lambda: st.session_state.update({"confirmed_doi": True}), ) # select the format of the output -fmt = st.sidebar.selectbox( +st.sidebar.selectbox( label="Format", options=["bibtex", "text", "rdf-xml", "turtle", "ris", "crossref-xml", "datacite-xml", "crossref-tdm", "bibentry"], index=0, @@ -103,14 +112,14 @@ def do_lookup(): on_change=lambda: st.session_state.update({"confirmed_doi": True}), ) # toggle arxiv2doi to be on or off -arxiv2doi = st.sidebar.checkbox( +st.sidebar.checkbox( label="arXiv ID to DOI", value=True, key="arxiv2doi", on_change=lambda: st.session_state.update({"confirmed_doi": True}), ) # toggle capitalize_title to be on or off -capitalize_title = st.sidebar.checkbox( +st.sidebar.checkbox( label="Capitalize title", value=False, key="capitalize_title", @@ -128,7 +137,7 @@ def do_lookup(): # input on the main page with col_input: - input_doi = st.text_input( + st.text_input( label="**DOI / PubMed ID / arXiv ID**", label_visibility="collapsed", value="", @@ -139,7 +148,7 @@ def do_lookup(): ) # a "Search" button to trigger the lookup on the right side of the input box with col_btn: - button = st.button( + st.button( label="Search", key="button", type="primary", @@ -147,10 +156,19 @@ def do_lookup(): on_click=lambda: st.session_state.update({"confirmed_doi": True}), ) -# a container for writing output -if st.session_state.get("confirmed_doi", False): - st.divider() -output_container = st.container() - +# perform the lookup (updates `last_render` in the session state) ... if st.session_state.get("confirmed_doi", False): do_lookup() + +# ... and (re-)render the last result, which persists across script re-runs +last_render = st.session_state.get("last_render") +if last_render is not None: + st.divider() + output_container = st.container() + with output_container: + if last_render["status"] == "success": + render_success(output_container, last_render["text"]) + elif last_render["status"] == "error": + output_container.error(last_render["text"]) + elif last_render["status"] == "redirect": + render_redirect(output_container, last_render["url"]) diff --git a/test/test_streamlit_app.py b/test/test_streamlit_app.py new file mode 100644 index 0000000..3c88ef3 --- /dev/null +++ b/test/test_streamlit_app.py @@ -0,0 +1,131 @@ +"""Tests for the Streamlit web app (`streamlit_app.py`), using the official +Streamlit AppTest framework. + +`BibLookup.__call__` is stubbed so no test touches the network; the tests +assert the app's reactive behavior: lookups triggered by pressing enter, +config changes re-rendering from the session cache without a new network +call, and results persisting across script re-runs. +""" + +import pytest + +pytest.importorskip("streamlit.testing") + +from streamlit.testing.v1 import AppTest # noqa: E402 + +from bib_lookup.bib_lookup import BibLookup # noqa: E402 + +APP_PATH = __file__.rsplit("/", 2)[0] + "/streamlit_app.py" + +CANNED_BIBTEX = "@article{X_2020,\n title = {Test Title},\n journal = {J},\n year = {2020}\n}" +LOOKUP_ERRORS = ["Not Found", "Network Error", "Timeout Error", "Format Error", "Parse Error"] +REDIRECT_MESSAGE = f"{BibLookup.__REDIRECT_FLAG__} Automatic lookup failed. Please visit: https://example.org/paper" + + +@pytest.fixture() +def stubbed_app(monkeypatch): + """Stub the network-facing methods of BibLookup and return an AppTest.""" + calls = [] + + def fake_call(self, identifier, **kwargs): + calls.append(identifier) + return fake_call.result + + def fake_to_bib_item(self, res, **kwargs): + return f"RENDERED({kwargs.get('align')}, {kwargs.get('capitalize_title')})" + + fake_call.result = CANNED_BIBTEX + monkeypatch.setattr(BibLookup, "__call__", fake_call) + monkeypatch.setattr(BibLookup, "_to_bib_item", fake_to_bib_item) + monkeypatch.setattr(BibLookup, "lookup_errors", property(lambda self: LOOKUP_ERRORS)) + + at = AppTest.from_file(APP_PATH, default_timeout=60) + at.run() + assert not at.exception + at.calls = calls # expose the stub call log on the AppTest object + at.fake_call = fake_call # expose the stub result for per-test tweaking + return at + + +def test_enter_triggers_lookup_without_button(stubbed_app): + at = stubbed_app + at.text_input[0].set_value("10.1234/abc").run() + assert at.calls == ["10.1234/abc"] + assert len(at.code) == 1 + assert CANNED_BIBTEX in at.code[0].value + + +def test_align_change_rerenders_from_cache(stubbed_app): + at = stubbed_app + at.text_input[0].set_value("10.1234/abc").run() + n_calls = len(at.calls) + + at.sidebar.selectbox[0].set_value("left").run() # alignment + assert len(at.calls) == n_calls, "align change must not trigger a new lookup" + assert "RENDERED(left, False)" in at.code[0].value + + at.sidebar.checkbox[1].set_value(True).run() # capitalize title + assert len(at.calls) == n_calls, "capitalize change must not trigger a new lookup" + assert "RENDERED(left, True)" in at.code[0].value + + +def test_format_round_trip_reuses_cache(stubbed_app): + at = stubbed_app + at.text_input[0].set_value("10.1234/abc").run() + n_calls = len(at.calls) + + at.sidebar.selectbox[1].set_value("text").run() # bibtex -> text: new query + assert len(at.calls) == n_calls + 1 + assert at.code[0].value == CANNED_BIBTEX + + at.sidebar.selectbox[1].set_value("bibtex").run() # text -> bibtex: cached + assert len(at.calls) == n_calls + 1, "returning to bibtex must reuse the cache" + # the cached bibtex entry is re-rendered locally via `_to_bib_item` + assert at.code[0].value == "RENDERED(middle, False)" + + +def test_result_persists_across_plain_rerun(stubbed_app): + at = stubbed_app + at.text_input[0].set_value("10.1234/abc").run() + at.run() # e.g. websocket reconnect: plain re-run without any widget change + assert len(at.code) == 1, "result must survive a plain script re-run" + assert len(at.calls) == 1 + + +def test_clearing_input_clears_result(stubbed_app): + at = stubbed_app + at.text_input[0].set_value("10.1234/abc").run() + at.text_input[0].set_value("").run() + assert len(at.code) == 0 + assert at.session_state["last_render"] is None + + +def test_redirect_result(stubbed_app): + at = stubbed_app + at.fake_call.result = REDIRECT_MESSAGE + at.text_input[0].set_value("10.9999/china-doi").run() + assert at.session_state["last_render"]["status"] == "redirect" + assert at.session_state["last_render"]["url"] == "https://example.org/paper" + assert len(at.warning) == 1 + + +def test_lookup_error_result(stubbed_app, monkeypatch): + at = stubbed_app + monkeypatch.setattr(BibLookup, "__call__", lambda self, identifier, **kw: "Not Found") + at.run() # re-run with the failing stub + at.text_input[0].set_value("10.1234/missing").run() + assert at.session_state["last_render"]["status"] == "error" + assert "Lookup Failed: Not Found" in at.error[0].value + + +def test_lookup_exception_result(stubbed_app, monkeypatch): + at = stubbed_app + + def boom(self, identifier, **kw): + raise RuntimeError("boom") + + monkeypatch.setattr(BibLookup, "__call__", boom) + at.run() + at.text_input[0].set_value("10.1234/abc").run() + assert at.session_state["last_render"]["status"] == "error" + assert "Error: boom" in at.error[0].value From dfa1e44b0b1d9f40a6db15ca2c06bcd2ec3141c6 Mon Sep 17 00:00:00 2001 From: WEN Hao Date: Thu, 29 Oct 2026 10:38:26 +0800 Subject: [PATCH 2/3] fix(test): skip streamlit app tests when streamlit.testing.v1 is absent Older streamlit releases (resolved on Python 3.9 in CI) ship streamlit.testing without the v1 submodule; name the full module in importorskip so collection skips cleanly instead of erroring. --- test/test_streamlit_app.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/test_streamlit_app.py b/test/test_streamlit_app.py index 3c88ef3..56690d2 100644 --- a/test/test_streamlit_app.py +++ b/test/test_streamlit_app.py @@ -9,7 +9,9 @@ import pytest -pytest.importorskip("streamlit.testing") +# name the full submodule: older streamlit versions have `streamlit.testing` +# but not `testing.v1`, and the app tests need its API +pytest.importorskip("streamlit.testing.v1") from streamlit.testing.v1 import AppTest # noqa: E402 From 2bdaf7d1c62623d40c4eca5ea2bc391fa4bf350c Mon Sep 17 00:00:00 2001 From: WEN Hao Date: Thu, 29 Oct 2026 10:44:52 +0800 Subject: [PATCH 3/3] feat: raise the minimum Python to 3.11, test 3.14 in CI - Python 3.9 (EOL 2025-10) and 3.10 (EOL 2026-10) are dropped from requires-python, the classifiers, and the pytest matrix; the current dependency ecosystem (streamlit >= 1.66, pandas 3.x, numpy 2.3) already requires >= 3.11, so 3.9/3.10 users only ever got a stale dependency set - add Python 3.14 to the pytest matrix (it was already claimed in the classifiers but never tested) - keep the streamlit app tests skippable on interpreters whose resolved streamlit lacks streamlit.testing.v1 --- .github/workflows/run-pytest.yml | 2 +- CHANGELOG.rst | 7 +++++++ pyproject.toml | 4 +--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/.github/workflows/run-pytest.yml b/.github/workflows/run-pytest.yml index c507403..e6e1d33 100644 --- a/.github/workflows/run-pytest.yml +++ b/.github/workflows/run-pytest.yml @@ -23,7 +23,7 @@ jobs: runs-on: ubuntu-22.04 strategy: matrix: - python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + python-version: ["3.11", "3.12", "3.13", "3.14"] fail-fast: false steps: diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 093af85..d18cc33 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -20,6 +20,13 @@ Added triggers a new network request. Covered by new AppTest-based tests in ``test/test_streamlit_app.py``. +Removed +~~~~~~~ +- Dropped support for Python 3.9 and 3.10 (both end-of-life — October 2025 and + October 2026 respectively); the minimum supported Python is now 3.11, and + Python 3.14 is now covered by the test matrix. Users on older interpreters + will automatically resolve the last compatible release from PyPI. + `0.2.0 `__ - 2026-10-28 -------------------------------------------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index c63cbb3..aea31d8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ dynamic = ["version"] description = "A useful tool for looking up Bib entries using DOI, or pubmed ID (or URL), or arXiv ID (or URL)." readme = "README.md" license = { file = "LICENSE" } -requires-python = ">=3.9" +requires-python = ">=3.11" authors = [ { name = "DeepPSP", email = "wenh06@gmail.com" }, ] @@ -17,8 +17,6 @@ classifiers = [ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13",