Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/run-pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 13 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,26 @@ Versioning <https://semver.org/spec/v2.0.0.html>`__.

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``.
- Docker packaging for the Streamlit web app: a multi-stage ``Dockerfile``
(``python:3.12-slim``, venv-isolated install, unprivileged runtime user,
built-in health check) plus a ``docker-compose.yml`` with
``restart: unless-stopped`` binding ``127.0.0.1:8501``. The PyPI package
stays library-only; the app is packaged from the repository snapshot.
The reverse-proxy configuration in front of it is unchanged.

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 <https://github.com/DeepPSP/bib_lookup/compare/v0.1.4...v0.2.0>`__ - 2026-10-28
--------------------------------------------------------------------------------------

Expand Down
4 changes: 1 addition & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
]
Expand All @@ -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",
Expand Down
148 changes: 83 additions & 65 deletions streamlit_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand All @@ -87,30 +96,30 @@ 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,
key="align",
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,
key="fmt",
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",
Expand All @@ -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="",
Expand All @@ -139,18 +148,27 @@ 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",
use_container_width=True,
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"])
133 changes: 133 additions & 0 deletions test/test_streamlit_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
"""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

# 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 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
Loading