Metadata-Version: 2.5
Name: apollodorus-client
Version: 0.1.0
Summary: Python client for the Apollodorus paper-search API
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx>=0.27
Provides-Extra: dev
Requires-Dist: black==26.5.1; extra == 'dev'
Requires-Dist: flake8==7.3.0; extra == 'dev'
Requires-Dist: isort==8.0.1; extra == 'dev'
Requires-Dist: mypy==2.1.0; extra == 'dev'
Requires-Dist: pytest==9.0.3; extra == 'dev'
Requires-Dist: python-dotenv==1.2.2; extra == 'dev'
Provides-Extra: dotenv
Requires-Dist: python-dotenv>=1.0; extra == 'dotenv'
Description-Content-Type: text/markdown

# apollodorus-client

Python client for the Apollodorus paper-search API: FluxGate's cache of top research
papers (quant finance, CS, statistics, economics), with search that reaches beyond it.

```bash
pip install apollodorus-client            # add [dotenv] to read a .env file
```

```python
from apollodorus_client import Apollodorus

with Apollodorus.from_env() as ap:        # APOLLODORUS_URL + APOLLODORUS_API_KEY
    hits = ap.search("momentum crash", effort="medium")
    for hit in hits:
        print(f"{hit.score:8.3f}  {hit.id}  cached={hit.cached}  {hit.title}")
    print(hits.sources, "partial" if hits.partial else "complete")
```

Python 3.10 or later; the only dependency is `httpx`. The server also serves a guide for AI
agents at `{APOLLODORUS_URL}/llms.txt` and its OpenAPI schema at
`{APOLLODORUS_URL}/openapi.json`.

## Configuration

`Apollodorus.from_env(*, owner=None, timeout=60.0)` reads three variables:

| Variable | Meaning |
|---|---|
| `APOLLODORUS_URL` | the API root, e.g. `https://<your-host>/apollodorus/api` (required) |
| `APOLLODORUS_API_KEY` | the API key, sent as the `X-API-Key` header (required) |
| `APOLLODORUS_BOOKMARK_OWNER` | optional: the name your bookmarks are kept under (`owner` overrides it) |

With the `[dotenv]` extra installed, a `.env` file in the working directory, or in a
directory above it, supplies what the environment lacks; a real environment variable wins
over the file. Only those three names are read, from either. `from_env()` raises
`RuntimeError` when the URL or the key is missing.

Or build the client yourself:

```python
import os

from apollodorus_client import Apollodorus

ap = Apollodorus(
    "https://<your-host>/apollodorus/api",
    os.environ["APOLLODORUS_API_KEY"],
    owner="my-project",  # optional: sent as X-Bookmark-Owner
    timeout=60.0,        # seconds per request; see "Papers, bookmarks, and the corpus" for the exceptions
)
```

- `base_url` must be an absolute `http://` or `https://` URL with a host.
- `api_key` must be visible ASCII: no spaces, control characters, or curly quotes.
  Whitespace around it, such as a trailing newline from a secrets file, is dropped. A
  refused key is never repeated in the error.
- `owner` is 1 to 64 lowercase letters, digits, `.`, `_`, or `-`, starting with a letter or
  digit. Without one, the server files your bookmarks under the shared owner `api-key`.
- `http_client` (optional) is an `httpx.Client` to send through. It is used as it is: never
  modified, never closed, and never given the key or the base URL, which go only on this
  client's own requests. Its own timeout then applies in place of `timeout`.

Keep the key in the environment or a `.env` file, never in code or URLs, and use an
`https://` URL. A plain `http://` `base_url` to any host but `localhost`, `127.0.0.0/8`, or
`::1` makes the constructor issue a `UserWarning`, because the key would travel in
cleartext.

## Effort levels

| `effort` | Matches by | Searches | Server time budget |
|---|---|---|---|
| `low` (default) | keywords | the cache: titles, abstracts, full text | 10 s |
| `medium` | keywords | the cache + Semantic Scholar, OpenAlex, CORE, arXiv | 30 s |
| `high` | meaning | the cache + Semantic Scholar | 60 s |
| `extra` | meaning | the cache + Semantic Scholar, OpenAlex, CORE, arXiv | 120 s |
| `max` | meaning, over full text | `extra`, then reads the top `top_n` papers in full | none (minutes) |

Keyword levels need every word, in any order; `"quoted words"` must appear as a phrase.
Meaning levels find papers about the query even when they share none of its words. A level
that runs out of time answers with what it found, and `hits.partial` is `True`. Start at
`low`, and go up when the results fall short. `EFFORTS` and `DEADLINES` (seconds, `None` for
`max`) are importable from the package.

## Searching

```python
from apollodorus_client import Apollodorus, JobStatus


def show(status: JobStatus) -> None:  # called with each poll's answer
    print(f"{status.status:<8} {status.stage}  {status.elapsed_s:.0f} s")


with Apollodorus.from_env() as ap:
    hits = ap.search(
        "regime switching asset allocation",
        effort="high",
        limit=50,                # 1-100 results, default 20
        since=2015, until=2024,  # publication years
        field="q-fin",           # a coarse field group: q-fin, econ, cs, stat, ...
        timeout=120,             # seconds the search may run; None waits as long as it takes
        on_progress=show,
    )
    print(hits.job_id, hits.partial, hits.sources, hits.elapsed_s)

    deep = ap.search("intraday momentum and liquidity", effort="max", top_n=10)  # minutes
    for hit in deep[:10]:
        print(hit.fulltext, hit.match, (hit.passage or "")[:200])
```

`search(query, *, effort="low", limit=20, since=None, until=None, field=None, top_n=20,
timeout=..., on_progress=None)` submits the search as a job on the server and long-polls it
until it is done. It returns `SearchResults`: a list of `SearchResult`, best first, that also
carries `.partial`, `.sources` (each source's `ok`, `failed`, `skipped`, or `timeout`),
`.job_id`, and `.elapsed_s`. `top_n` (1–50) is read at `max` only: the papers whose full text
is read. Out-of-range values (a `limit` of 500, say) are refused by the server, as an
`ApollodorusError` with status 422. `on_progress` receives a `JobStatus` (`id`, `status`,
`stage`, `elapsed_s`, `heartbeat_age_s`, `partial`, `sources`) for every answer the server
gives.

Each `SearchResult` has:

| Field | Meaning |
|---|---|
| `id` | what `fetch()` takes: a paper id (`2301.12345`, `ssrn-…`, `journal-W…`), `doi:…` for a paper known only by its DOI, or `None` when no source we can fetch from knows it |
| `title`, `authors`, `year`, `source`, `citation_count`, `url` | the paper |
| `cached` | whether the cache holds it |
| `score` | BM25 at the keyword levels, cosine similarity otherwise: compare it only within one search |
| `match` | `keyword`, `meaning`, or `fulltext` |
| `exact` | every query word (and quoted phrase) occurs in its text |
| `passage` | the matching snippet (at `max`, the best full-text chunk), or `None` |
| `fulltext` | at `max`: whether its full text was read; `None` otherwise |
| `found_by` | the searches that found it: `index` (the cache), `s2`, `openalex`, `core`, `arxiv` |

A `SearchResult` is immutable: `authors` and `found_by` are tuples, so a result can go in a
set or key a dict. A result only CORE found has `found_by == ("core",)`.

### How long a search may run

- By default `timeout` is the level's server budget plus 30 s: 40 s at `low`, 60 s at
  `medium`, 90 s at `high`, and 150 s at `extra`. `max` has no limit. Pass a number of
  seconds to set one, or `None` for none.
- The clock starts once the server has taken the search. Time spent queued behind other
  searches doesn't count: the clock restarts on every poll that finds the search still
  queued.
- A resubmitted search (see Retries) gets the whole `timeout` again, and the pause before
  it isn't charged to it.
- Each poll waits on the server for up to 25 s, and never past the time left.
- Past the limit, `search()` raises `SearchTimeout`.

### Results the cache doesn't hold

Results with `cached=False` can still be read: when `hit.id` isn't `None`, `fetch(hit.id)`
caches the paper now. Do it only for papers whose text you need now. The server caches
uncached results that clear its citation bar in the background, when its auto-cache is on,
unless only CORE found them: CORE isn't limited to the project's fields, so those wait for
a `fetch`.

## Papers, bookmarks, and the corpus

```python
from apollodorus_client import Apollodorus

with Apollodorus.from_env(owner="my-project") as ap:
    hits = ap.search("volatility targeting", limit=5)
    meta = ap.paper(hits[0].id)  # dict: title, abstract, authors, status, text_source, citation_count, ...
    body = ap.text(hits[0].id)   # str: the full text (just the abstract for abstract-only papers)

    uncached = next((hit for hit in hits if not hit.cached and hit.id), None)
    if uncached is not None:              # only when you need its text now
        meta = ap.fetch(uncached.id)      # slow (tens of seconds); the id may be "doi:..."
        body = ap.text(meta["arxiv_id"])  # read it under the id the fetch returns

    paper_id = meta["arxiv_id"]
    ap.bookmark(paper_id, note="hedging section")  # pins the paper against eviction
    ap.bookmark(paper_id)                          # no note given: the note stays
    ap.bookmark(paper_id, note=None)               # clears the note
    page = ap.bookmarks(limit=20, offset=0)        # yours, newest first: {"results", "total", "limit", "offset"}
    ap.unbookmark(paper_id)                        # {"removed": True, "published": ...}

    print(ap.stats())   # corpus counts and footprint, from the nightly snapshot
    print(ap.health())  # {"status": "ok"}
```

- `paper(id)`: one paper's metadata, whatever its `status` (`active`, `evicted`, or
  `parse_failed`), including whether you bookmarked it.
- `text(id)`: a cached paper's full text.
- `fetch(id)`: caches a paper now, from any source, and returns its metadata. The id may be
  a search result's `doi:…`. Use the `arxiv_id` it returns from then on: it can differ from
  the id you sent (a version suffix dropped, a merged work, the same paper under another
  source). Fetching a paper the cache already holds returns it as it is.
- `bookmark(id, note=...)`: bookmarks a paper for your owner, fetching it first if it isn't
  cached. The answer's `published` is `False` while the change is still on its way to the
  shared index: in flight, never lost.
- `unbookmark(id)`: removes it. Removing a bookmark that isn't there is not an error.
- `bookmarks(*, limit=20, offset=0)`: one page of your bookmarks.
- `stats()` and `health()`: the corpus's counts and footprint, and a liveness check.

`fetch` and `bookmark` allow 300 s, since they can download and parse a paper, and each of
`search()`'s polls allows its wait plus 30 s. Every other request uses the client's
`timeout`.

## Retries

- `search()` and the reads (`paper`, `text`, `bookmarks`, `stats`, `health`) retry a 429, a
  502, 503, or 504, and a dropped connection: 5 attempts in all, about 1, 2, 4, then 8 s
  apart. Each pause varies by up to 25% either way, so clients that failed together don't
  come back together.
- A `Retry-After` header on a 429 or 503, in seconds or as an HTTP date, is waited for when
  it asks for longer than that pause, for at most 30 s. The server sends `Retry-After: 5`
  when its search queue is full or it is restarting.
- A search the server lost (a poll answered 404, after a restart, say) is submitted again at
  once. One that failed, or that stalled (running, but silent for over 60 s), is submitted
  again after a pause of about 1, 2, then 4 s. A search is resubmitted 3 times at most,
  whatever the causes.
- The writes (`fetch`, `bookmark`, `unbookmark`) make one attempt, since a write whose answer
  was lost may still have landed.
- A request this client built wrong (`httpx.LocalProtocolError`) is never retried.

## Errors

- `ApollodorusError` (`.status`, `.detail`): the API answered with an error, after any
  retries. It is also raised when a search failed or stalled through 3 resubmits, or
  reported a status this client doesn't know, and when the answer can't be the API's:
  - A redirect is never followed, so the key can't be forwarded to another host. Use the
    final `https://` address.
  - A web page means `base_url` is likely the web UI's address: the API is under it at
    `/api`.
- `SearchTimeout` (a `TimeoutError`): the search ran past `search()`'s `timeout`.
- `httpx.TransportError`: the server stayed unreachable through every attempt.
- `ValueError`: a bad argument, such as an unknown `effort`, a malformed `owner` or
  `api_key`, or a `base_url` that isn't an absolute `http(s)://` URL with a host.
- `RuntimeError`: `from_env()` found no URL or no key.

Both exception types pickle and copy cleanly, so they cross process boundaries.

## Threads and lifetime

One client per process is enough: it pools connections. It keeps no per-call state, and the
`httpx.Client` it wraps can be shared between threads, so every thread can use the same
client. `search()` blocks the calling thread until the results arrive. Use the client as a
context manager, or call `close()` when you're done with it. Closing never closes an
`http_client` you passed in.
