Skip to content

Narrow litfetch to the fetch surface: source ladder, resolvers, HTTP session - #1

Merged
folded merged 5 commits into
mainfrom
fetch-surface
Jul 3, 2026
Merged

folded merged 5 commits into
mainfrom
fetch-surface

Conversation

@folded

@folded folded commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator

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.

…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.

@folded folded left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Docs-vs-code consistency pass. One finding is a runnable bug (the README resolver signature); the rest are readable-consistency polish, two with apply-able suggestions. Note: the original audit's #2 (requires unset) and #3 (chain stopping condition) were checked against the code and are not issues — requires is set on every fetcher and chain._run does gate on all three ids — so they're dropped.

Comment thread README.md Outdated
Comment thread docs/api.md Outdated
Comment thread docs/api.md Outdated
Comment thread docs/api.md
Comment thread docs/api.md
Comment thread docs/api.md Outdated
- 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.
@folded

folded commented Jul 3, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed all six in fbc2aae — README resolver signature now takes (ArticleIds, Http), and the api.md polish (NCBI always-keyless, S2 Rate intervals, PmcOaFetcher dual-protocol note, serde-not-re-exported, named email= sites). Resolving the threads.

Comment thread litfetch/artifacts.py
Comment thread litfetch/crossref.py
Comment thread litfetch/fetchers.py
Comment thread litfetch/relations.py
Comment thread litfetch/semantic_scholar.py
Comment thread litfetch/source_metadata.py
Comment thread litfetch/unpaywall.py
folded added 3 commits July 3, 2026 12:51
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.
- 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.
…lations

- 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.
@folded
folded merged commit 9ee565b into main Jul 3, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant