epmcminer is a PyQt6 desktop application that lets researchers search the Europe PMC REST API for open-access academic papers and bulk-download their PDFs. The wizard flow is: Search → Preview → Download → Summary.
- Three-layer separation
- Data models
- API layer
- Service layer
- GUI layer
- Qt signals and slots
- Testing
- Tooling
GUI ──► Service ──► API Client ──► Europe PMC / ORCID
◄── ◄── ◄──
- GUI (
gui/) emits Qt signals and reads typed dataclasses. It never touchesrequests, never constructs a query string, never reads raw dicts. - Services (
services/) own all business logic: query building, parallelism, skip logic, report generation. They never import fromgui/. - API clients (
api/client.py,api/orcid_client.py) are thin wrappers — HTTP calls only, no business logic.
Every service receives its dependencies through __init__ (dependency injection). There are no module-level singletons. services/__init__.py::create_application_services() is the single wiring point — it creates one EuropePMCClient, passes it to both SearchService and DownloadService, then returns the four services as a tuple for MainWindow to inject into each screen.
All inter-layer data transfer uses typed dataclasses — raw dicts never cross a layer boundary.
| Class | Location | Purpose |
|---|---|---|
Paper |
api/paper.py |
Single result: pmid, doi, title, authors, journal, year, abstract, pdf_url |
SearchParams |
api/search_params.py |
All user inputs: query, dates, pub types, licenses, ORCIDs, count, output_folder. Validates itself in __post_init__: count > 0, dates are valid ISO-8601, date_from ≤ date_to |
SearchResult |
api/search_result.py |
papers list, total_found, estimated_downloadable |
DownloadResult |
services/download_result.py |
One per attempted paper: paper, status (Literal), reason, file_path, active_threads |
DownloadResult lives in services/ not api/ — it is the output of business logic (skip decisions, write operations), not an HTTP response model. The STATUS_* ClassVars are typed ClassVar[Literal["downloaded"]] etc. so mypy verifies every status string at call sites.
Uses a persistent requests.Session for connection reuse. Two public methods:
search(query, page_size, sort, cursor_mark) — hits the Europe PMC search endpoint with format=json, resultType=core. The core result type embeds fullTextUrlList inline, avoiding a second API call to resolve PDF URLs. Every query is wrapped with (HAS_FT:Y OR HAS_FREE_FULLTEXT:Y) to restrict to open-access papers.
download_pdf(url, cancel_event) — fetches a direct PDF URL. Both methods share the same retry policy: up to 3 attempts, exponential backoff (1 s, 2 s), honouring Retry-After on HTTP 429. Backoff sleeps use Event.wait(timeout=delay) rather than time.sleep() so they wake immediately when a cancellation is requested. On HTTP 200 the response body is validated against the %PDF magic bytes — if the server returned an HTML challenge page, InvalidPdfContentError is raised and the paper is skipped rather than written as garbage.
Single method check_exists(orcid) — GET to pub.orcid.org/v3.0/{orcid}. Returns True on HTTP 200, False otherwise. Raises ConnectionError on network failures (including timeouts).
build_query(params) — pure string construction. Multi-word queries without explicit boolean operators are auto-joined with AND. Handles the Europe PMC pub-type mapping quirk (stored lowercase; two types have non-obvious spellings). Prepends a catch-all clause so regular journal articles are not excluded by explicit pub-type filters. The free-full-text filter is not added here — it is the client's invariant applied on every request.
preview(params) — fetches one page (10 results), maps raw dicts to Paper via paper_from_raw(), counts papers with a pdf_url, returns a SearchResult.
paper_from_raw() is a module-level helper (not a method). It handles the pmid/id duality: MED (PubMed) records carry a pmid field; non-MED records (preprints etc.) only carry the generic id field.
download(params, progress_callback, cancel_event) — two-phase loop to minimise overshoot:
- Batch mode (remaining ≥ 10): fetch a full page, download all papers in parallel using
ThreadPoolExecutor(max_workers=2).progress_callbackfires as each individual paper completes, not at page boundaries. - Single-paper mode (remaining < 10): fetch and download one at a time so the loop can stop exactly at the target count.
Pagination uses Europe PMC's cursor system: each response includes a nextCursorMark. The loop advances until the cursor stops changing (end of results) or the target count is reached.
Skip logic in _download_one: a paper is skipped (not failed) when there is no pdf_url, when the file already exists on disk, or when the server returns a non-PDF body. Failures are reserved for unrecoverable HTTP errors and write errors.
active_threads counter: a shared int inside _download_page, protected by a threading.Lock. The counter is captured before decrement — so each DownloadResult reports the thread count including the finishing thread, matching the intuitive peak value.
Why MAX_WORKERS=2: Europe PMC rate-limits at higher concurrency. Two workers sustain throughput without triggering HTTP 429s. The retry/backoff logic handles the cases where 429s occur anyway.
Generates three export formats from the same list[DownloadResult]:
- CSV via pandas
to_csv - Excel via pandas + openpyxl
to_excel - PDF via reportlab
SimpleDocTemplate— mirrors the Summary screen's dark colour palette with a three-column stat card (downloaded / skipped / total found), a search-parameters card, and per-paper sections for downloaded and skipped papers.
Two-stage validation: validate_format() checks the ORCID pattern and ISO 7064 MOD 11-2 checksum locally (no network). check_exists() delegates to OrcidClient and must be called from a background thread.
Owns a QStackedWidget holding the four screens and a step-indicator bar. Screens communicate back to MainWindow exclusively via Qt signals — a screen never navigates itself and never imports another screen.
create_application_services() is called once in MainWindow.__init__ and the resulting service instances are injected into each screen constructor.
Every blocking operation (API calls, file I/O) runs in a QThread worker subclass. The pattern used throughout:
- Instantiate a worker dataclass with the data it needs.
- Connect its signals (
result_ready,error_occurred,progress) to GUI slots. - Call
worker.start(). - Slots update the UI on the main thread when signals fire — Qt's signal/slot mechanism guarantees thread-safe delivery into the event loop.
Download cancellation (screen 3): DownloadWorker holds a threading.Event. The cancel button sets it; the download loop checks it between pages; Event.wait() in the HTTP client's backoff sleep wakes immediately when set; in-flight HTTP requests complete naturally without retry.
Preview cancellation (screen 2): PreviewWorker carries its own cancel_event. When a new search fires while a preview is still loading, the old worker's event is set and its result/error signals are silently discarded — the main thread starts the new worker immediately without blocking on wait().
| Widget | Description |
|---|---|
TagInput |
Free-text or dropdown tag entry. Uses a custom QLayout subclass (FlowLayout) that wraps tags onto new lines. ORCID fields fire a background worker to validate against the public registry on confirm. |
ProgressWidget |
Dual-mode: loading (indeterminate bar + animated pulsing dots) and progress (determinate bar, percentage, count, live ETA, active-thread dot indicators). |
DatePicker |
Custom calendar popup replacing the default QDateEdit widget. |
Toast |
Slide-in success/failure notification using QGraphicsOpacityEffect for the fade animation. |
A signal is a typed announcement that an object can fire. A slot is any callable that is registered to receive it. The connection is declared once; after that, emitting the signal automatically calls every connected slot — the emitter never needs to know who is listening.
# Declaration (on the class, not an instance)
class MyWorker(QThread):
result_ready = pyqtSignal(str) # will carry one str argument
# Connection (in the caller)
worker.result_ready.connect(self._on_result)
# Emission (inside the worker)
self.result_ready.emit("hello") # _on_result("hello") is calledThink of it as a strongly-typed callback system built into Qt — but with one critical extra property: cross-thread safety.
GUI frameworks require that all UI updates happen on the main thread (the thread that owns the event loop). Worker threads do blocking I/O and must not touch widgets directly. Signals solve this:
- When a signal is emitted from a background thread, Qt queues the call as an event in the main thread's event loop.
- The main thread picks it up between user interactions and runs the slot safely.
- No mutexes, no
QMetaObject.invokeMethodboilerplate — the framework handles it.
Screen → MainWindow navigation. A screen never imports MainWindow or navigates itself. Instead it declares a signal and emits it. MainWindow connects it to navigate_to():
ScreenSearch.search_requested ──connect──► MainWindow._on_search_requested
(calls navigate_to(1) + starts preview)
This keeps screens decoupled: ScreenSearch has no idea what happens next.
Worker → GUI progress updates. Every QThread worker owns its own signals. DownloadWorker emits three:
| Signal | Argument | Connected slot |
|---|---|---|
progress_updated |
DownloadResult |
ScreenDownload._on_progress |
download_finished |
list[DownloadResult] |
ScreenDownload._on_finished |
error_occurred |
str |
ScreenDownload._on_error |
The worker thread calls self.progress_updated.emit(result) for each paper. Qt marshals this into the main thread's event loop; _on_progress then updates the progress bar and appends a log row — both safe UI operations.
pyqtSignal(DownloadResult) pins the argument type at class definition time. Emitting the wrong type raises at runtime, and mypy catches mismatches statically. The list in download_finished = pyqtSignal(list) is unparameterised because PyQt6's C++ bridge cannot represent generic types; the slot manually annotates the argument as list[DownloadResult].
- They are not async/await. Emission is synchronous in the emitting thread; delivery happens when the receiving thread's event loop next runs.
- They are not a message bus. Each connection is point-to-point, declared explicitly, and visible in the code.
- They do not replace return values. A worker emits results via a signal because it runs on a different thread; two objects on the same thread that need to exchange data just call methods normally.
612 tests across unit and integration suites.
- Unit tests mock all HTTP with the
responseslibrary orpytest-mock. GUI tests run headless with aQApplicationfixture. - Integration tests use
pytest-recording(VCR cassettes). Real HTTP responses are captured once and replayed deterministically.--block-networkis set as the default pytestaddoptsso any un-mocked request fails loudly. To re-record:pytest tests/integration/ --record-mode=all --override-ini="addopts=". - Coverage: 96% overall; services and API layer at 100%.
| Tool | Configuration |
|---|---|
| ruff | Rules: E, F, W, I, B (bugbear), C4 (comprehensions), UP (pyupgrade), SIM (simplify). Ignores B008, SIM108. |
| mypy | disallow_untyped_defs, disallow_any_generics, warn_return_any, warn_unused_ignores. 0 errors. |
| pytest | --block-network default; VCR cassettes for integration tests. |