Ingestion
This page documents the behavior of biometaharmonizer.ingestion in
full, derived entirely from its source code.
Overview
ingest() is the single entry point for all
data retrieval. It accepts a list of NCBI accessions (or a path to a text file
containing them), fetches the corresponding BioSample XML records from NCBI
Entrez, parses and harmonizes each record, and returns a
pandas.DataFrame conforming to the fixed 53-column output schema
defined in _load_final_schema().
Module-level Configuration Functions
Three module-level setters configure shared state before (or instead of)
passing arguments directly to ingest().
All three write module-level globals and are not thread-safe — see
Thread-Safety below.
- set_email(email: str) None
Set
ENTREZ_EMAILandBio.Entrez.email. Validated against the pattern^[^@\s]+@[^@\s]+\.[^@\s]+$. In addition to format validation, the following well-known placeholder addresses are explicitly rejected withValueErrorbecause NCBI guidelines require a real contact address:your@email.com example@example.com user@example.org test@test.com email@example.com
- Raises:
ValueError – For addresses that fail the regex or match any of the placeholder strings above.
- set_api_key(key: str) None
Set
ENTREZ_API_KEYandBio.Entrez.api_key. Raises the NCBI rate limit from 3 to 10 requests/second, roughly tripling throughput on large datasets. There is no format validation; any string is accepted.import biometaharmonizer as bmh bmh.set_api_key("abc123def456...") df = bmh.ingest(source="ids.txt", email="researcher@institution.edu")
- set_cache_dir(path) None
Override
CACHE_DIRand call_read_assembly_summary_cached.cache_clear()to invalidate the internalfunctools.lru_cacheon the assembly summary reader. Side effect: any assembly summary data cached in memory from a previous call toingest()in the same process is discarded. Subsequent calls will re-read from the new path (or re-download if the files are absent).import biometaharmonizer as bmh bmh.set_cache_dir("/content/bmh_cache") # Google Colab example
ID Classification
Before any network request, _classify_ids() partitions the input into three
buckets by prefix:
Assembly accessions (
GCF_,GCA_) — require two-step resolution to BioSample accessions.BioSample accessions (
SAMN,SAME,SAMD) — fetched directly.Unrecognized IDs — logged at WARNING level and skipped entirely; they produce no rows in the output.
The classification is case-insensitive and checked via startswith.
Input Deduplication
After loading IDs from a file or list, _deduplicate() removes exact
duplicate accession strings before any network call. The number of removed
duplicates is logged at INFO level:
INFO biometaharmonizer.ingestion: Removed 12 duplicate input IDs.
Empty Input Behavior
If the deduplicated accession list is empty (because the input file was
empty or contained only whitespace/blank lines), ingest() logs a WARNING
and returns an empty DataFrame with all 57 schema columns — it does not
raise an exception:
WARNING biometaharmonizer.ingestion:
ingest() called with an empty accession list. Returning empty DataFrame.
This is distinct from the ValueError case below.
Error Cases
ingest() raises the following exceptions:
``ValueError`` — raised when no valid email is provided (neither via the
emailparameter nor via a priorset_email()call), or when all IDs are unrecognized/unresolvable and the final BioSample list is empty after classification and assembly resolution. The error message includes a human-readable explanation:ValueError: No valid BioSample IDs could be resolved. Reasons: 12 unrecognized IDs (expected SAMN/SAME/SAMD/GCF/GCA prefixes)
``FileNotFoundError`` — raised by
_load_ids()whensourceis a string orPaththat looks like a file path but does not exist on disk:FileNotFoundError: Input file not found: /data/missing.txt
Assembly Accession Resolution
Assembly accessions cannot be fetched directly from the biosample Entrez
database. They are resolved to BioSample accessions via two sequential passes:
Pass 1 — Local assembly index:
The function _ensure_assembly_summaries() downloads and caches two NCBI
assembly summary flat files on first use:
assembly_summary_refseq.txtfromhttps://ftp.ncbi.nlm.nih.gov/genomes/ASSEMBLY_REPORTS/assembly_summary_refseq.txtassembly_summary_genbank.txtfromhttps://ftp.ncbi.nlm.nih.gov/genomes/ASSEMBLY_REPORTS/assembly_summary_genbank.txt
Both are stored in CACHE_DIR (default: ~/.biometaharmonizer/cache/).
The TTL is 7 days (_CACHE_TTL_DAYS). Files older than this are
deleted and re-downloaded automatically. Setting refresh_cache=True
forces deletion and re-download regardless of age.
Once cached, _resolve_assembly_to_biosample() searches both files for
matching assembly_accession values and extracts the corresponding
biosample column value.
Pass 2 — Entrez elink fallback:
Any assembly accession not found in the local index is forwarded to
_resolve_gcx_via_entrez(), which performs:
esearch(db="biosample",
term="GCF_XXXXXX.1[Accession] OR ...",
usehistory="n")
esummary(db="biosample", id=<UIDs from esearch>)
The Accession field from each DocumentSummary element is extracted
and mapped back to the original GCF/GCA input.
esearch + efetch Pipeline for BioSample Accessions
For native BioSample accessions the pipeline is:
esearch(db="biosample",
term="SAMN...[Accession] OR ...",
usehistory="y") <- per batch
efetch(db="biosample",
WebEnv=<env>,
query_key=<key>,
retstart=0,
retmax=fetch_batch_size,
rettype="full",
retmode="xml") <- one call per page
The usehistory="y" flag instructs NCBI to store the result set in a
named server-side History slot (WebEnv + query_key). Each
ingest() call creates a fresh History
slot per batch; this avoids the cross-batch accumulation bug where a single
query_key would incorrectly index only the final batch.
Batch Size Constants
Constant |
Default |
Controls |
|---|---|---|
|
200 |
Records per |
|
100 |
Accessions per |
Higher fetch_batch_size values reduce HTTP round trips but produce larger
XML payloads per response and may increase per-request latency.
Note
fetch_batch_size is silently clamped to _NCBI_EFETCH_MAX = 500.
Passing a value above 500 triggers a WARNING log and the effective batch
size is reduced to 500 to prevent truncated XML responses from NCBI:
WARNING: fetch_batch_size=1000 exceeds NCBI-recommended maximum of 500.
Clamping to 500 to avoid truncated XML responses.
Rate Limits and Inter-Batch Sleep
NCBI enforces the following limits:
Without API key: 3 requests/second (
inter_batch_sleep = 0.34s)With API key: 10 requests/second (
inter_batch_sleep = 0.12s)
The inter_batch_sleep variable is set inside _fetch_biosample_metadata
by checking the module-level ENTREZ_API_KEY. The sleep is applied after
every esearch batch and after every efetch call.
Retry Logic
Every Entrez call is wrapped in a retry loop using the following constants:
Constant |
Value |
Meaning |
|---|---|---|
|
3 |
Maximum number of attempts per call. |
|
2 |
Base of the exponential backoff (seconds). |
|
30 |
Maximum wait between retries (seconds). |
The wait duration for attempt n (1-indexed) is:
wait = min(_RETRY_BASE_S ** n, _RETRY_MAX_S)
= min(2^n, 30) seconds
So the sequence is: 2 s, 4 s, 8 s (capped at 30 s for large n).
Retries are triggered by instances of _TRANSIENT_EXCEPTIONS:
urllib.error.URLError, http.client.HTTPException, ConnectionError,
TimeoutError, and OSError.
HTTP 429 Handling
NCBI HTTP 429 (“Too Many Requests”) is treated separately from the
normal transient exception backoff. When a 429 is detected the retry logic
waits a flat 60 seconds (_HTTP_429_WAIT_S = 60) before the next
attempt — regardless of the attempt number — and logs at WARNING level:
WARNING biometaharmonizer.ingestion:
HTTP 429 (rate limit) from NCBI. Waiting 60s before retry 2/3...
This flat wait is longer than the normal exponential sequence (2 s, 4 s, 8 s) because 429 signals that NCBI’s rate-limiting window has not yet expired. As soon as NCBI returns a non-429 response the normal backoff resumes.
Note
The last retry attempt (attempt _MAX_RETRIES) never sleeps, regardless
of error type — it tries immediately and then gives up if it fails.
Null Normalization
Every attribute value parsed from NCBI XML is passed through
_normalize_null(value). A value is normalized to None if it is:
Literally
Noneor a pandas NA.Empty string or whitespace-only.
Matched by the
_NULL_PATTERNSregex (case-insensitive):Dash/dot placeholders:
-,--,.,...Explicit nulls:
n/a,na,nd,nr,ns,nt,none,null,nilMissing variants:
missing,misssing,missng,misingUnknown variants:
unknown,unkown,unknwon,unknow“Not X” phrases:
not provided,not collected,not applicable,not available,not determined,not recorded,not reported,not known,not given,not stated,not specified,not done,not tested,not sequenced,not typedOther descriptors:
unavailable,unspecified,undetermined,unidentifiedRestricted/access terms:
restricted,restricted access,withheld,confidentialPlaceholder abbreviations:
tbd,tbaPrefixed null phrases: any string matching
missing:.*,not applicable:.*, ordata agreement established pre-2023
Suppressed and Invalid Accessions
When a BioSample accession is suppressed, withdrawn, or otherwise invalid,
NCBI returns no <BioSample> element for it in the efetch XML response. The
accession simply produces no row in the output. At the end of ingest(),
the number of records returned is logged; discrepancies between input count
and output row count indicate suppressed or invalid accessions:
WARNING biometaharmonizer.ingestion:
NOT resolved (suppressed/invalid after both passes): 2
Unresolved: ['GCF_000000001.1', 'SAMN00000000']
Ingest Summary Log
At the end of every successful ingest() call the following lines are
emitted at INFO level:
============================================================
INGEST SUMMARY
Input IDs provided : <N>
Assembly accessions : <n_gcx> (only if GCF/GCA input)
Resolved via local index : <n>
Resolved via Entrez elink (fallback) : <n>
fetch_batch_size : <N>
esearch_batch_size : <N>
Records in output : <N>
bioproject_accession filled : <n> / <N>
assembly_accession_refseq filled : <n> / <N>
assembly_accession_genbank filled : <n> / <N>
============================================================
BioProject and Assembly Back-Fill
After fetching BioSample XML, ingest() performs a second pass against the
local assembly index to back-fill bioproject_accession,
assembly_accession_refseq, and assembly_accession_genbank columns for
records whose BioSample accession appears in the index. This is logged at INFO
level with the count of records updated.
_extra_attributes
Any BioSample attribute whose harmonized_name (or attribute_name) does
not resolve to a known final output column via the synonym lookup is stored in
the _extra_attributes column as a JSON-serialized dict.
Pipe-separated overflow values: When the same key appears multiple times on
a single record (a valid NCBI pattern for repeatable attributes), the values are
joined with a | pipe separator.
Attribute collision on schema columns (``_dup_<field>`` keys): A different
encoding is used when the same standard schema column receives two values
from two separate <Attribute> elements on the same BioSample record. The
first value is stored in the schema column as usual. The second (and any
further) value is stored in _extra_attributes under the key
_dup_<standard_key> — for example _dup_isolation_source or
_dup_host. Multiple overflow values for the same field are themselves
pipe-joined. This convention is the reliable way to detect and retrieve all
submitted values for any schema field:
import json
ea = json.loads(row["_extra_attributes"] or "{}")
# Primary value:
primary = row["isolation_source"]
# Any duplicates:
duplicates = ea.get("_dup_isolation_source", "").split("|")
The antibiogram list (if present) is stored under the "antibiogram" key as
a native Python list before the single json.dumps(extras) call serializes
the whole dict.
Thread-Safety
ENTREZ_EMAIL, ENTREZ_API_KEY, and CACHE_DIR are module-level
globals. All three functions set_email(), set_api_key(), and
set_cache_dir(), as well as ingest() itself (which calls all three
setters internally when their corresponding parameters are supplied), write
these globals without locking.
Consequence: concurrent calls to ingest() from different threads with
different credentials will silently overwrite each other’s state. This can
produce requests that use the wrong email or API key without raising any error.
Recommended pattern for parallel workloads: use a
multiprocessing.Pool (each subprocess has its own memory space) rather
than a threading.Thread pool. If threads are required, serialize all calls
to ingest() with a threading.Lock.