Skip to content

Commit 9ee565b

Browse files
authored
Narrow litfetch to the fetch surface: source ladder, resolvers, HTTP session (#1)
* Narrow litfetch to the fetch surface: source ladder, resolvers, HTTP session litfetch resolves a scholarly-article identifier bundle (ArticleIds: pmid / pmcid / doi) to its retrievable files and fetches their bytes. It owns identity, the file-set model, and the act of fetching; rendering and storage stay the consumer's (see CONTEXT.md). Surface: - fetch_body walks a Fetcher ladder for the article body (JATS / publisher XML): PMC OA (S3), Europe PMC, Elsevier, Springer OpenAccess. bioRxiv/medRxiv is opt-in (litfetch[biorxiv]; browser-fingerprint fetch, off the default ladder). - list_files / fetch_file enumerate and materialise the file-set across FileSources: PMC OA renditions + supplementary material, plus PDF renditions from Unpaywall, Semantic Scholar, Crossref TDM, and Springer (Meta API). A discovered PDF is a BODY rendition, never a fetch_body result. - Identifier resolvers (Europe PMC, NCBI ID Converter, Semantic Scholar) enrich the bundle; chain() composes them. - resolve_access reports licence / OA terms (from Unpaywall, or read from the artifact bytes); related_ids links preprint <-> published versions. HTTP session (ADR 0001): Session is the object callers hold -- it owns the pooled client (injectable client_factory), per-host polite pacing, and retry with exponential backoff / 429 handling; the operations are methods on it. session.scope() adds a short-lived response cache so a duplicate upstream call within one unit of work is served once. The HTTP primitives (the Http protocol, Rate, RetryPolicy) live in _http, so sources depend on the narrow Http protocol, not the concrete Session. File downloads follow redirects; DOIs are validated and percent-encoded before use. Entitlement: a source marks a subscription File with credential_key=INSTITUTIONAL, which the consumer routes through an EZproxy-style client_factory (documented pattern; no proxy code in litfetch). Docs: README (usage), CONTEXT.md (domain model), docs/api.md (reference), docs/adr/0001 (the Session decision), docs/source-expansion-plan.md, and source references under docs/sources/. Type-checked (ships py.typed); 127 tests; the static-check gate runs via pre-commit. * Docs: fix resolver example signature; polish api.md per review - README: my_resolver now takes the (ArticleIds, Http) the Resolver protocol requires (was ArticleIds -> ArticleIds, which would TypeError when the ladder passes the session); prose + import updated to match. - api.md: NcbiIdConverterResolver noted as always-keyless (no keyed variant); S2 Rate rows given req/s intervals like the NCBI rows; note that PmcOaFetcher implements both Fetcher and FileSource (no PmcOaFileSource); serde functions noted as litfetch.serde (not top-level re-exported); Contact-defaults names the two email= sites. * Drop the hardcoded contact email; make it a Session(contact=...) knob The maintainer's email is no longer baked into the source. Session gains a contact param (default None): it appends (mailto:...) to the User-Agent only when set, and flows to the polite-pool params via a new Http.contact attribute. Sources read http.contact for their email/mailto -- Unpaywall (required email; skipped when absent), Crossref mailto (omitted when absent), NCBI email (omitted). NcbiIdConverterResolver drops its email arg; resolve_access / UnpaywallFileSource keep an email override defaulting to the session contact. A scope inherits the parent's contact. Tests supply an explicit test contact where a request is expected, plus new tests: contact->User-Agent, scope inheritance, and Unpaywall declining without a contact. Docs (README, api.md) updated; the api.md 'Contact' section rewritten. * Address review: namespace INSTITUTIONAL, log non-JSON responses - artifacts: INSTITUTIONAL -> 'litfetch:institutional' so the entitlement sentinel can't collide with a user credentials key; annotate the public string constants Final. (Media types stay str -- an open domain, arbitrary content-types -- so no enum; the closed sets are already enums.) - Log a warning on every silent 'except ValueError' guarding resp.json() (crossref, unpaywall, semantic_scholar, resolvers._get_json, relations, fetchers x2) -- a malformed 200 no longer returns None without a trace, per the never-swallow-silently rule. Left the deliberate broad excepts (defusedxml parse in source_metadata, curl_cffi in _fetch_impersonated): both already log and their error surfaces are unspecified/open. * Address review round: validate retry, guard scope entry, case-fold relations - RetryPolicy rejects max_attempts < 1 (0 fell through to the unreachable guard). - Session.scope() entered before its parent now raises a named RuntimeError instead of the generic client-property error. - related_ids dedupes on a case-folded DOI key (DOIs are case-insensitive), so bioRxiv and Crossref naming one DOI in different case yield one entry. - _extract_jats_article anchors on <article followed by whitespace or >, so a <article-set>/<article-meta> wrapper cannot be mistaken for the root. - Free resolve_access passes email to Unpaywall only, not the session contact. - Tests for each of the above.
1 parent a5b5685 commit 9ee565b

46 files changed

Lines changed: 5825 additions & 976 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
* @folded @lgruen-cpg

.github/workflows/lint.yml

Lines changed: 7 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -7,38 +7,22 @@ on:
77
branches: [main]
88

99
jobs:
10-
ruff:
10+
pre-commit:
11+
# Runs every hook in .pre-commit-config.yaml — the whole static-check gate.
1112
# Skip draft PRs.
1213
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
1314
runs-on: ubuntu-latest
1415
permissions:
1516
contents: read
1617
steps:
1718
- uses: actions/checkout@v6
18-
1919
- uses: astral-sh/setup-uv@v8.2.0
2020
with:
2121
enable-cache: true
22-
22+
# Pre-sync the lint env so the pyright hook's `uv run` is a no-op.
2323
- run: uv sync --locked --group lint --python 3.13
24-
25-
- run: uv run ruff check .
26-
27-
- run: uv run ruff format --check .
28-
29-
pyright:
30-
# Skip draft PRs.
31-
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
32-
runs-on: ubuntu-latest
33-
permissions:
34-
contents: read
35-
steps:
36-
- uses: actions/checkout@v6
37-
38-
- uses: astral-sh/setup-uv@v8.2.0
24+
- uses: actions/cache@v6
3925
with:
40-
enable-cache: true
41-
42-
- run: uv sync --locked --group lint --python 3.13
43-
44-
- run: uv run pyright
26+
path: ~/.cache/pre-commit
27+
key: pre-commit-${{ hashFiles('.pre-commit-config.yaml') }}
28+
- run: uv run pre-commit run --all-files --show-diff-on-failure

.github/workflows/release.yml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
name: release
2+
3+
# Publishes to PyPI via Trusted Publishing (OIDC) — no API token. The PyPI
4+
# project's trusted publisher must reference this repo, this workflow filename,
5+
# and the `pypi` environment. Triggered on a published GitHub Release; the tag
6+
# must match the package version (guarded below).
7+
on:
8+
release:
9+
types: [published]
10+
11+
jobs:
12+
build:
13+
runs-on: ubuntu-latest
14+
permissions:
15+
contents: read
16+
steps:
17+
- uses: actions/checkout@v6
18+
- uses: astral-sh/setup-uv@v8.2.0
19+
with:
20+
enable-cache: true
21+
# Publishing a version is irreversible, so fail loudly if the release tag
22+
# (vX.Y.Z) doesn't match the version in pyproject.toml.
23+
- name: Check tag matches package version
24+
run: |
25+
tag="${GITHUB_REF_NAME#v}"
26+
pkg="$(uv version --short)"
27+
if [ "$tag" != "$pkg" ]; then
28+
echo "Release tag '$GITHUB_REF_NAME' does not match package version '$pkg'"
29+
exit 1
30+
fi
31+
- run: uv build
32+
- uses: actions/upload-artifact@v4
33+
with:
34+
name: dist
35+
path: dist/
36+
37+
publish:
38+
needs: build
39+
runs-on: ubuntu-latest
40+
# The trusted-publisher and OIDC scope both key off this environment name.
41+
environment: pypi
42+
permissions:
43+
id-token: write # mint the OIDC token PyPI exchanges for an upload token
44+
steps:
45+
- uses: actions/download-artifact@v4
46+
with:
47+
name: dist
48+
path: dist/
49+
- uses: pypa/gh-action-pypi-publish@release/v1

.github/workflows/tests.yml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,15 +11,16 @@ jobs:
1111
# Skip draft PRs.
1212
if: github.event_name != 'pull_request' || !github.event.pull_request.draft
1313
runs-on: ubuntu-latest
14+
strategy:
15+
fail-fast: false
16+
matrix:
17+
python-version: ['3.10', '3.11', '3.12', '3.13']
1418
permissions:
1519
contents: read
1620
steps:
1721
- uses: actions/checkout@v6
18-
1922
- uses: astral-sh/setup-uv@v8.2.0
2023
with:
2124
enable-cache: true
22-
23-
- run: uv sync --locked --group test --python 3.13
24-
25-
- run: uv run pytest
25+
- run: uv sync --locked --group test --python ${{ matrix.python-version }}
26+
- run: uv run pytest -q

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,3 +165,6 @@ test_eq.pdf
165165
eval_findings.jsonl
166166
compare_pandoc.jsonl
167167
grading.jsonl
168+
169+
# litcache default cache directory
170+
.litcache/

.markdownlint.json

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// https://github.com/DavidAnson/markdownlint#optionsconfig
2+
// Disabling some rules here as we find them too restrictive.
3+
{
4+
"default": true, // Include all rules by defauls
5+
"line-length": false, // To allow working with soft wraps in editors
6+
"no-inline-html": { // Sometimes we need to use <img> html tags in GitHub markdown,
7+
// as it doesn't allow setting the image size with markdown tags
8+
"allowed_elements": ["details", "img"]
9+
},
10+
"ul-indent": false, // To allow indenting the whole list to distinguish it visually
11+
"no-multiple-blanks": false // To allow multiple blank lines between a header and
12+
// the previous paragraph
13+
}

.pre-commit-config.yaml

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# lint.yml runs `pre-commit run --all-files`, so this config is the CI gate too:
2+
# a hook added here is gated in CI with no workflow edit. pytest (tests.yml) stays
3+
# a separate CI job — too slow for the per-commit path.
4+
repos:
5+
- repo: https://github.com/pre-commit/pre-commit-hooks
6+
rev: v6.0.0
7+
hooks:
8+
- id: check-yaml
9+
- id: end-of-file-fixer
10+
- id: trailing-whitespace
11+
- id: check-case-conflict
12+
- id: check-merge-conflict
13+
- id: detect-private-key
14+
- id: debug-statements
15+
- id: check-added-large-files
16+
17+
- repo: https://github.com/igorshubovych/markdownlint-cli
18+
rev: v0.38.0
19+
hooks:
20+
- id: markdownlint
21+
22+
- repo: https://github.com/populationgenomics/pre-commits
23+
rev: v0.1.3
24+
hooks:
25+
- id: cpg-id-checker
26+
27+
- repo: https://github.com/astral-sh/ruff-pre-commit
28+
rev: v0.15.17
29+
hooks:
30+
- id: ruff
31+
- id: ruff-format
32+
33+
# pyright needs the project deps to resolve imports, so it runs in the lint env
34+
# via `uv run`; `--group lint` provisions it on demand. Whole-project (include
35+
# set in pyproject), so pass_filenames: false; fires on any staged .py.
36+
- repo: local
37+
hooks:
38+
- id: pyright
39+
name: pyright
40+
entry: uv run --group lint pyright
41+
language: system
42+
files: \.py$
43+
pass_filenames: false
44+
45+
# YAML formatter (ruff can't format YAML).
46+
- repo: https://github.com/google/yamlfmt
47+
rev: v0.21.0
48+
hooks:
49+
- id: yamlfmt

.yamlfmt

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# YAML formatter config; the YAML analogue of ruff-format / biome.
2+
# retain_line_breaks_single: yamlfmt can't selectively strip blank lines
3+
# between block-sequence items, so the no-blank-line-between-steps layout in
4+
# .github/workflows is kept by hand — this setting only stops yamlfmt from
5+
# reintroducing or multiplying blank lines elsewhere.
6+
formatter:
7+
type: basic
8+
retain_line_breaks_single: true
9+
trim_trailing_whitespace: true
10+
eof_newline: true

CLAUDE.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# litfetch development notes
2+
3+
litfetch resolves a scholarly article identifier to its retrievable files and to
4+
the markdown derived from them. See [`CONTEXT.md`](CONTEXT.md) for the domain
5+
model and language, and [`README.md`](README.md) for usage.
6+
7+
## Working norms
8+
9+
Operating directives for Claude (and any agent) in this repo; they counteract default
10+
model dispositions.
11+
12+
- **Resist the minimal-diff reflex.** Don't reach for the smallest change that hides the
13+
symptom (special-casing, papering over root causes). Aim for the correct fix at the
14+
right complexity level — not the smallest, not gold-plated.
15+
- **Fail loudly and early.** Raise on a missing expected input or precondition; never fall
16+
back to a default/placeholder to limp along. A placeholder is an explicit caller input,
17+
never a code default.
18+
- **Push back; don't just comply.** When a design, name, or approach seems worse —
19+
including a shortcut you're asked to take — say so with reasoning, unprompted. The
20+
author owns the final call.
21+
- **Offer better alternatives with trade-offs.** When a materially better approach than
22+
the proposed one exists, present it and the trade-offs — don't just execute the ask.
23+
- **Investigate before producing.** Read the code and verify constraints first. Don't
24+
treat a training-pattern convention as load-bearing unchecked; don't speculate about
25+
what you can read.
26+
- **Explain non-obvious changes first.** For a change whose rationale isn't self-evident,
27+
give the why before showing or applying the diff.
28+
- **Ask when unsure** rather than assume intent.
29+
- **No intensifiers or emphasis filler.** Drop words and phrases that add emphasis but no
30+
information — "that's the key", "crucially", "importantly", "the key insight", "it's
31+
worth noting". State the point plainly. Applies to all prose: chat replies, PR/review
32+
comments, commit messages, and docs.
33+
34+
## Code style
35+
36+
@docs/style/general.md
37+
@docs/style/python.md
38+
39+
## Docs
40+
41+
The primary audience for docs is a model reading them as context; humans second. Be
42+
terse: state each decision, mechanism, and rationale once — no rhetorical emphasis, no
43+
persuasion, no recaps. Every token written is re-paid on every future read.
44+
45+
## Committing
46+
47+
- **Stage explicit paths**, not `git add -A` / `.`.
48+
- **Pre-commit is the full static-check gate** (`.pre-commit-config.yaml`): lint, format,
49+
hygiene, and pyright. CI runs the same hooks via `pre-commit run --all-files`, so the
50+
two can't drift. Ensure hooks are installed (`pre-commit install`) — if not, install or
51+
ask the author; never bypass with `--no-verify`.
52+
- **Correct a pushed branch with a new commit on top**, not amend + force-push. PRs
53+
squash-merge, so `main` history stays linear regardless and intermediate fixups vanish
54+
on merge. Reserve force-push for rebasing a branch onto `main`.
55+
56+
## CI and review
57+
58+
- **Pin third-party GitHub Actions to the latest stable release**: the moving major tag
59+
(`@v3`) where the action publishes one, else the exact latest version (`@v8.2.0`). Verify
60+
against the action's releases when adding or bumping one.

CONTEXT.md

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
# litfetch
2+
3+
litfetch resolves a scholarly article identifier to its retrievable files —
4+
full-text body and supplementary material — and fetches their bytes. It owns
5+
*what an article's files are and how to fetch them*; it does not own *where a
6+
consumer stores them* nor *how they are rendered* (e.g. XML → markdown).
7+
8+
## Language
9+
10+
### Identity
11+
12+
**Article**:
13+
A scholarly paper litfetch retrieves, identified by an `ArticleIds` bundle.
14+
*Avoid*: paper, document, work, record (a `record` is the consumer's cached wrapper).
15+
16+
**ArticleIds**:
17+
The immutable identity bundle — any of `pmid`, `pmcid`, `doi`. A thin record;
18+
resolvers enrich it, sources consume whichever identifier they `require`.
19+
*Avoid*: identifiers, keys, ids.
20+
21+
### The file-set
22+
23+
**File-set**:
24+
The collection of files that make up a retrieved article — the body and any
25+
supplementary material, in each of the forms (media types) they are available
26+
in. The unifying model litfetch owns and a consumer de/serialises.
27+
*Avoid*: assets, contents, bundle (a bundle is `ArticleIds`).
28+
29+
**File**:
30+
One file in the set — a body rendition or a supplementary item, in one media
31+
type, with a known `source`, `media_type`, `size`, and an upstream location. The
32+
single ref type; supersedes the former split between `AlternateRepresentation`
33+
and `SupplementaryFile`.
34+
*Avoid*: representation, rendition, supplementary file, artifact (a `RawArtifact`
35+
is a File once its bytes are in hand).
36+
37+
**Body**:
38+
The file-set member that is the article full text itself — the file a consumer
39+
renders (e.g. to markdown). Distinct from supplementary material.
40+
*Avoid*: main file, primary document.
41+
42+
**Supplementary**:
43+
A file-set member that is *additional* material — figures, datasets, tables —
44+
not the article body.
45+
*Avoid*: supplement, attachment, extra.
46+
47+
### Sourcing
48+
49+
Every File is hosted upstream (PMC, a publisher). litfetch holds its `uri`,
50+
owning `source`, and the `credential_key` a fetch needs. The consumer cannot
51+
construct these — only litfetch knows the upstream layout and auth.
52+
*Avoid*: remote file, hosted ref, source file.
53+
54+
### Metadata
55+
56+
**Source metadata**:
57+
Facts about *access and provenance* that litfetch owns: the owning `source`, the
58+
upstream `uri`, the `credential_key` required, and the **licence** / access
59+
terms under which the file may be used. Licence carries a **basis**
60+
*extracted* from the fetched bytes (JATS `<license>`, Elsevier
61+
`openaccessUserLicense`; authoritative for exactly those bytes) or *asserted* by
62+
an external access authority (Unpaywall) when the bytes carry none (a PDF).
63+
litfetch returns the licence raw; mapping to an SPDX id is the consumer's.
64+
*Avoid*: provenance metadata, access info.
65+
66+
**Bibliographic metadata**:
67+
Descriptive facts about the article — title, authors, journal, date.
68+
**Out of litfetch's scope entirely**: it neither owns the shape nor surfaces the
69+
raw provider results, even though its resolvers' API calls return such fields. A
70+
consumer that wants bibliographic data calls the provider APIs itself (it
71+
controls the scoping) and feeds the resulting identifiers to litfetch. See the
72+
boundary below.
73+
*Avoid*: citation, bib data, article metadata.
74+
75+
## Ownership boundary
76+
77+
The seam between litfetch and its consumers. Data flows *through* litfetch
78+
because the consumer can neither construct the file refs (they need upstream
79+
URLs and per-source auth) nor fetch their bytes without it.
80+
81+
**litfetch owns**: identity (`ArticleIds`); the File-set model; the act of
82+
fetching (uri + credential routing per source); source metadata and license; and
83+
the canonical structural de/serialisation of all of these (a backend-agnostic
84+
dict mapping — not a wire format).
85+
86+
**The consumer owns**: placement — filesystem/store layout, blob storage,
87+
record `status`, leases — the *shape* of bibliographic metadata, and *rendering*
88+
(turning a fetched body into markdown or other derived forms).
89+
90+
**litfetch is not a bibliographic-metadata client**: resolvers return only
91+
`ArticleIds` and stay that way — litfetch will not surface the bibliographic
92+
fields S2/NCBI return, even raw. Doing so would force litfetch to fix each
93+
provider call's scope (e.g. S2's `fields=`) and thereby dictate what is fetched;
94+
the only coherent alternative is consumer-controlled scoping, but a consumer in
95+
control of those calls can resolve identifiers itself and hand them to litfetch
96+
directly. So litfetch touches provider APIs for three purposes only — completing
97+
identifiers, fetching files, and resolving **access terms** (licence / OA status
98+
via Unpaywall, which is access metadata, not bibliographic) — and surfaces only
99+
their results. Bibliographic fields stay out of scope.

0 commit comments

Comments
 (0)