sci-etl-core
A reusable, domain-agnostic Python library for scientific text mining and
ETL. sci-etl-core gives you composable building blocks — extractors, parsers,
LLM clients, embedding memory, processors, exporters, and state managers —
behind abstract base classes, so you can assemble a pipeline for any corpus
without inheriting constants tied to a specific field of science.
The library is async-first. Every component is an async implementation,
orchestrated by AsyncETLPipeline. For scripts that don't want to manage an
event loop, ETLPipeline is a single blocking entrypoint that runs the same
pipeline on a background loop.
Table of Contents
- Features
- Supported Sources
- Installation
- Quick Start
- Post-Processing and Visualization
- Semantic Memory (Optional)
- State, Resuming, and Errors
- Graceful Shutdown
- Rate Limiting
- Synchronous Components
- Logging
- Architecture
- Testing
- Contributing
- Security
- License
Features
- Pluggable async interfaces for every stage:
AsyncExtractor,Parser,AsyncLLMClient,AsyncRelevanceFilter,AsyncEntityExtractor,AsyncExporter,AsyncStateManager, plusAsyncEmbedder,TextChunker, andAsyncEmbeddingStorefor semantic memory. Existing blocking implementations plug in throughSync*Adapterwrappers. - Built-in orchestration —
AsyncETLPipelineprocesses records with bounded concurrency;ETLPipelinewraps it for blocking code. - Explicit failure signaling — a transport fault or malformed listing
aborts the run with
PipelineAborted(carrying the partial count) instead of looking like the end of the data. A single failing record is logged and left for the next run, and records that keep failing while nothing is processed stop the run instead of burning through the rest of the listing. - Resumable, crash-safe state — plain-file or SQLite backends record processed ids and the listing offset; CSV and metadata writes use atomic renames.
- Semantic memory (optional) — chunk and embed full texts into an in-memory or SQLite vector store, search for similar articles, or gate relevance by embedding similarity instead of an LLM call.
- Dependency injection everywhere — HTTP clients, parsers, models, prompts, and destinations are constructor arguments.
- Concrete implementations included — arXiv extractor; OpenAI-compatible chat and embedding clients; local sentence-transformers embedder; PDF / LaTeX / HTML parsers; CSV upsert, SQL table, and 3D Plotly exporters; dataframe processors and record validators.
- Typed configuration from YAML +
.envwith Pydantic validation andSecretStrAPI keys. - Offline test suite — pytest with mocks, Hypothesis property tests, and ABC conformance tests.
- PEP 561 typed (
py.typed) for downstream type checking.
Supported Sources
AsyncArxivExtractor is the only extractor that ships with the library. Every
source has its own protocol, pagination model, ID scheme, and full-text
formats, so each one gets its own AsyncExtractor rather than a single
extractor with switches for every source. The pipeline works with any class
that implements this contract:
from sci_etl_core import AsyncExtractor
from sci_etl_core.models import RawRecord
class MySourceExtractor(AsyncExtractor):
async def search(self, query: str, max_results: int, start_index: int) -> bytes | None: ...
def parse_listing(self, raw_listing: bytes, seen_ids: set[str]) -> tuple[list[RawRecord], int]: ...
async def fetch_full_text(self, record: RawRecord) -> str: ...
searchreturns one raw listing page. If the source can't be reached, it raisesUpstreamErrorinstead of returning an empty value; if the source rejects the request outright, it raisesExtractionError. Either one aborts the run.parse_listingreturns the records whose ids aren't inseen_ids, plus the number of entries on the page, counting the skipped ones. A count of0ends the run. If the payload can't be read, it raisesMalformedResponseError.fetch_full_textreturns the best text available for a record.
| Source | Status | How to use it |
|---|---|---|
| arXiv | Bundled | AsyncArxivExtractor |
| bioRxiv | Not bundled | Adapt AsyncArxivExtractor |
| ChemRxiv | Not bundled | Adapt AsyncArxivExtractor |
| PubMed | Not bundled | Implement your own AsyncExtractor |
| Crossref | Not bundled | Implement your own AsyncExtractor |
Installation
Python 3.10 or newer is required.
pip install "sci-etl-core[async,llm,pdf]" # everything the Quick Start uses
pip install "sci-etl-core[full]" # every bundled component except local embeddings
# or, from a clone:
pip install -e ".[full]"
The base install covers configuration, both pipelines, the state backends, the sync adapters, HTML and LaTeX parsing, text chunking, and the pandas processor steps. Components load their optional dependencies only when you import them, so add the extras for the components you use:
| Extra | Adds | Needed for |
|---|---|---|
async |
httpx, aiofiles, aiolimiter |
AsyncArxivExtractor, build_async_client, AsyncCsvUpsertExporter, load_config_async, AioLimiterRateLimiter |
llm |
openai, tiktoken |
AsyncOpenAICompatibleClient, token-based truncation |
pdf |
pdfplumber |
PdfPlumberParser |
sql |
sqlalchemy, aiosqlite |
AsyncSqlTableExporter |
viz |
plotly, aiofiles |
AsyncPlotly3DExporter |
cluster |
scikit-learn, numpy |
ClusteringStep |
embeddings |
numpy, openai |
AsyncOpenAIEmbedder, the vector stores, AsyncEmbeddingRelevanceFilter |
embeddings-local |
numpy, sentence-transformers |
AsyncSentenceTransformerEmbedder |
dev |
pytest and plugins, hypothesis |
running the test suite |
Importing a component whose extra is missing raises ModuleNotFoundError
naming the package to install.
Quick Start
Async pipeline
import asyncio
from sci_etl_core import (
AsyncArxivExtractor,
AsyncCsvUpsertExporter,
AsyncETLPipeline,
AsyncFileStateManager,
AsyncLLMEntityExtractor,
AsyncLLMRelevanceFilter,
AsyncOpenAICompatibleClient,
PipelineAborted,
)
from sci_etl_core.http_async import build_async_client
from sci_etl_core.parsers import LatexTarballParser, PdfPlumberParser
from sci_etl_core.processors import DefaultKeyNormalizer
RELEVANCE_PROMPT = (
"Decide whether the paper reports measurements of galaxies. "
'Reply with JSON: {"relevant": true} or {"relevant": false}.'
)
EXTRACTION_PROMPT = (
"Extract every measured object from the paper. Reply with JSON: "
'{"items": [{"name": "...", "value_a": 0.0, "value_b": 0.0}]}.'
)
async def main() -> None:
client = build_async_client()
llm = AsyncOpenAICompatibleClient(
api_key="sk-...",
base_url="https://api.openai.com/v1",
model="gpt-4o-mini",
)
pipeline = AsyncETLPipeline(
extractor=AsyncArxivExtractor(
client=client,
pdf_parser=PdfPlumberParser(),
latex_parser=LatexTarballParser(),
),
relevance_filter=AsyncLLMRelevanceFilter(
llm_client=llm, system_prompt=RELEVANCE_PROMPT
),
entity_extractor=AsyncLLMEntityExtractor(
llm_client=llm, system_prompt=EXTRACTION_PROMPT
),
exporter=AsyncCsvUpsertExporter(
key_column="name",
value_columns=["value_a", "value_b"],
normalizer=DefaultKeyNormalizer(),
),
state_manager=AsyncFileStateManager("state/processed.txt", "state/metadata.json"),
destination="results.csv",
max_concurrency=4,
logger=print,
closeables=[client, llm],
)
async with pipeline:
try:
processed = await pipeline.run(query="all:galaxy", total_limit=50)
except PipelineAborted as exc:
print(f"Stopped early after {exc.partial_count} records: {exc}")
return
print(f"Processed {processed} relevant records")
asyncio.run(main())
What a run does:
- Fetches a listing page (
page_sizerecords, default 100) starting at the offset saved by the state manager (or atstart_index=, if given), and skips records already processed. A page that holds only processed records is passed over, not treated as the end of the data. - For each remaining record, with at most
max_concurrencyin flight: relevance filter → full-text fetch → optional memory ingest → entity extraction → export → mark processed. Irrelevant records are marked processed without fetching full text. Records whoserecord_idis missing or blank can't be tracked, so they are skipped and logged. - Saves the listing offset and repeats until
total_limitrelevant records are processed or the listing is empty, waitingsleep_betweenseconds (default 0) before each further page. The offset only moves past pages whose records were all settled; see State, Resuming, and Errors.
total_limit counts relevant records only, is never exceeded, and defaults
to page_size. max_concurrency and page_size must be at least 1 and
total_limit must not be negative; other values raise ValueError before any
request is made. The legacy max_records= argument sets both values. AsyncArxivExtractor also
waits sleep_before_search seconds (default 3) before every listing request,
to respect arXiv's rate limits.
On exit, async with pipeline awaits aclose() on every entry in
closeables that has one — the HTTP client, the LLM client, and any SQLite
state manager or embedding store you use.
Prompts must ask for JSON. AsyncOpenAICompatibleClient requests JSON mode
(response_format={"type": "json_object"}), and OpenAI's API rejects JSON-mode
requests whose messages never mention "JSON". The response shapes the library
reads:
AsyncLLMRelevanceFilterreads therelevantkey. It accepts a boolean,0/1, or the strings"true","false","yes","no","1", and"0"in any case. Anything else, including a missing key, is treated as an error and returnsdefault_on_error.AsyncLLMEntityExtractorreads the list underresult_key(default"items"), or the only value if the response has exactly one key. The list must hold objects;nullmeans no entities and a lone object counts as one. Any other shape raisesLLMError, so the record is retried.AsyncCsvUpsertExportertakes each item'skey_columnvalue as the row key, so the extraction prompt must ask for that field.
Blocking usage
ETLPipeline is the only synchronous class. It accepts the same arguments as
AsyncETLPipeline — including the same async collaborators — plus
run_timeout:
from sci_etl_core import (
AsyncArxivExtractor,
AsyncCsvUpsertExporter,
AsyncFileStateManager,
AsyncLLMEntityExtractor,
AsyncLLMRelevanceFilter,
AsyncOpenAICompatibleClient,
ETLPipeline,
PipelineAborted,
)
from sci_etl_core.http_async import build_async_client
from sci_etl_core.parsers import LatexTarballParser, PdfPlumberParser
from sci_etl_core.processors import DefaultKeyNormalizer
# RELEVANCE_PROMPT and EXTRACTION_PROMPT as in the async example.
client = build_async_client()
llm = AsyncOpenAICompatibleClient(
api_key="sk-...",
base_url="https://api.openai.com/v1",
model="gpt-4o-mini",
)
with ETLPipeline(
extractor=AsyncArxivExtractor(
client=client,
pdf_parser=PdfPlumberParser(),
latex_parser=LatexTarballParser(),
),
relevance_filter=AsyncLLMRelevanceFilter(llm_client=llm, system_prompt=RELEVANCE_PROMPT),
entity_extractor=AsyncLLMEntityExtractor(llm_client=llm, system_prompt=EXTRACTION_PROMPT),
exporter=AsyncCsvUpsertExporter(
key_column="name",
value_columns=["value_a", "value_b"],
normalizer=DefaultKeyNormalizer(),
),
state_manager=AsyncFileStateManager("state/processed.txt", "state/metadata.json"),
destination="results.csv",
closeables=[client, llm],
run_timeout=3600,
) as pipeline:
try:
processed = pipeline.run(query="all:galaxy", total_limit=50)
except PipelineAborted as exc:
processed = exc.partial_count
print(f"Processed {processed} relevant records")
- There are no blocking versions of individual components. To call an
extractor, client, or exporter directly from synchronous code, wrap the calls
in a coroutine and run it with
asyncio.run. To plug your own blocking implementations into a pipeline, see Synchronous Components. run_timeoutis in seconds and unlimited by default. When it expires, the run is cancelled andTimeoutErroris raised.- Event loop.
ETLPipelineruns the pipeline on a shared background event-loop thread. It works from plain scripts, and also when called from inside a running event loop (for example a notebook). It still blocks the calling thread until the run finishes, so in async code awaitAsyncETLPipelineinstead. Once anETLPipelinehas used a collaborator, that collaborator belongs to the background loop — don't also use it from your own event loop. with ETLPipeline(...)closescloseableson exit, allowing up to 30 seconds per resource.
Configuration
from pathlib import Path
from sci_etl_core import BaseAppConfig, load_config
config = load_config(BaseAppConfig, Path("config.yaml"), Path(".env"))
print(config.llm.model, config.pipeline.max_records)
# config.yaml
llm:
base_url: https://api.openai.com/v1
model: gpt-4o-mini
timeout: 120
http:
user_agent: "my-project/1.0 (mailto:you@example.org)"
max_retries: 3
timeout: 25
full_text:
max_concurrency: 4
pipeline:
search_query: "all:galaxy"
max_records: 100
page_size: 100
search_delay: 3.0
sleep_between: 5.0
max_workers: 6
| Section | Model | Fields (defaults) | Pass to |
|---|---|---|---|
llm |
LLMConfig |
api_key, base_url (https://api.openai.com/v1), model (gpt-4o-mini), timeout (120) |
AsyncOpenAICompatibleClient |
http |
HttpConfig |
user_agent (sci-etl-core/0.1), max_retries (3), backoff_factor (2.0), timeout (25) |
build_async_client, AsyncArxivExtractor |
full_text |
RateLimitConfig |
max_concurrency (4), max_rate (unset), time_period (1.0) |
build_rate_limiter |
pipeline |
PipelineConfig |
search_query (""), max_records (100), page_size (100), search_delay (3.0), sleep_between (5.0), max_workers (6) |
AsyncETLPipeline, AsyncArxivExtractor, run() |
Components don't read the config on their own — copy the values into the constructors:
from sci_etl_core import AsyncArxivExtractor, AsyncOpenAICompatibleClient
from sci_etl_core.http_async import build_async_client
from sci_etl_core.parsers import LatexTarballParser, PdfPlumberParser
client = build_async_client(timeout=config.http.timeout, user_agent=config.http.user_agent)
extractor = AsyncArxivExtractor(
client=client,
pdf_parser=PdfPlumberParser(),
latex_parser=LatexTarballParser(),
max_retries=config.http.max_retries,
backoff_factor=config.http.backoff_factor,
sleep_before_search=config.pipeline.search_delay,
)
llm = AsyncOpenAICompatibleClient(
api_key=config.llm.api_key, # a SecretStr is accepted as-is
base_url=config.llm.base_url,
model=config.llm.model,
default_timeout=config.llm.timeout,
)
# Then: AsyncETLPipeline(..., max_concurrency=config.pipeline.max_workers)
# and: run(query=config.pipeline.search_query,
# page_size=config.pipeline.page_size,
# total_limit=config.pipeline.max_records,
# sleep_between=config.pipeline.sleep_between)
- API key. The key comes from the
LLM_API_KEYenvironment variable (choose another withapi_key_env_var=), which can be loaded from a.envfile: the one you pass, or else the first.envfound from the current working directory upward. It is stored as a PydanticSecretStr, so it doesn't show up in reprs or logs. Variables already set in the environment take precedence over.env; copy.env.exampleto get started. - The environment wins over YAML. When the variable is set, it overrides
any
llm.api_keyin the YAML file, which is used only as a fallback. Keep keys out of config files anyway. - Project-specific settings.
BaseAppConfigaccepts extra top-level keys, or you can subclass it. - Async loading.
load_config_asynctakes the same arguments. - Errors. A missing or unparseable YAML file, a file whose top level isn't
a mapping, and a validation failure all raise
ConfigurationError. Validation checks ranges too: counts such asmax_workers,page_size,max_retries, andmax_concurrencymust be at least 1, timeouts andtime_periodmust be positive, and delays andmax_recordsmust not be negative.
Post-Processing and Visualization
The pipeline's exporter receives each record's entities as a list[dict], and
AsyncCsvUpsertExporter is the built-in exporter that accepts that shape. It
keeps one row per normalized key: later records only fill empty cells, value
columns are converted to floats (anything non-numeric becomes empty), and
optional numeric_clip bounds clamp them.
The exporter reads the existing file (which must be UTF-8) before its first write. If that read fails, for example because of a different encoding or a malformed row, the export raises and the file is left untouched instead of being overwritten. On Windows, a write is retried for about a second and a half while another program holds the file open.
Keys come straight from LLM output, so a key a spreadsheet would run as a
formula (starting with =, +, -, @, a tab, or a carriage return) is
written with a leading apostrophe. The exporter strips it again when it
reloads the file; other tools reading the CSV see it. Pass
escape_formulas=False to write keys unchanged.
Cleanup, scoring, and plots are a separate step over a DataFrame:
import asyncio
import pandas as pd
from sci_etl_core import AsyncPlotly3DExporter, ScatterPlotConfig
from sci_etl_core.processors import (
CompletenessStep,
DeduplicationStep,
DefaultKeyNormalizer,
NormalizationStep,
ProcessorChain,
QualityFlagStep,
)
frame = pd.read_csv("results.csv", dtype={"name": str})
clean = ProcessorChain(
[
NormalizationStep("name", DefaultKeyNormalizer()), # adds _norm_key
DeduplicationStep("_norm_key"), # one row per key
CompletenessStep(["value_a", "value_b"]), # adds completeness_pct
QualityFlagStep(), # adds quality_flag
]
).process(frame)
plot = AsyncPlotly3DExporter(
ScatterPlotConfig(
x_column="value_a",
y_column="value_b",
z_column="completeness_pct",
color_column="quality_flag",
hover_name_column="name",
title="Corpus overview",
)
)
asyncio.run(plot.export(clean, "overview.html"))
clean then looks like this:
| _norm_key | name | value_a | value_b | completeness_pct | quality_flag |
|---|---|---|---|---|---|
| objecta | Object A | 12.4 | 0.87 | 100.0 | Confirmed |
| objectb | Object B | 9.1 | 50.0 | Needs Review | |
| objectc | Object C | 0.0 | Low Confidence |
The Plotly exporter drops rows that are missing any axis value. The other building blocks:
ClusteringStep(feature_extractor)runs DBSCAN over features returned by your ownFeatureExtractor.- Record validators check individual entity dicts:
NumericRangeValidator,KeywordExclusionValidator, andCompositeValidator. The pipeline doesn't call them, so apply them in your own entity extractor or before export. AsyncSqlTableExporter(table_name)writes a DataFrame to a SQLAlchemy async URL, e.g.await AsyncSqlTableExporter("entities").export(clean, "sqlite+aiosqlite:///results.db"). Like the Plotly exporter, it takes a DataFrame, so use it after post-processing rather than as the pipeline's exporter.
Semantic Memory (Optional)
Pass a memory_ingestor to chunk and embed each relevant record's full text
into a vector store. You can then search that store by meaning:
from sci_etl_core.embeddings import (
AsyncChunkIngestor,
AsyncOpenAIEmbedder,
AsyncSimilarArticleFinder,
AsyncSqliteEmbeddingStore,
SlidingWindowChunker,
)
embedder = AsyncOpenAIEmbedder(
api_key="sk-...",
base_url="https://api.openai.com/v1",
model="text-embedding-3-small",
)
store = AsyncSqliteEmbeddingStore("memory.db")
ingestor = AsyncChunkIngestor(chunker=SlidingWindowChunker(), embedder=embedder, store=store)
# Add to the pipeline from the Quick Start:
# AsyncETLPipeline(..., memory_ingestor=ingestor, closeables=[client, embedder, store])
async def show_similar(text: str) -> None:
finder = AsyncSimilarArticleFinder(embedder, store)
for record_id, score, metadata in await finder.find_similar_articles(text, top_k=5):
print(f"{score:.2f} {record_id} {metadata['title']}")
-
What gets stored. Ingestion runs after the relevance gate, so only relevant records are embedded.
SlidingWindowChunkerdefaults to 350-word windows with a 50-word overlap. Re-ingesting a record replaces all of its chunks, so text that now yields fewer passages leaves nothing stale behind. -
Failures. An
EmbeddingErrororEmbeddingStoreErrorduring ingestion is logged, and the record's entities are still exported. That includes a memory file that isn't a SQLite database, and an embedder that returns a different number of vectors than passages. -
Stores.
InMemoryEmbeddingStore()suits tests and short-lived runs.AsyncSqliteEmbeddingStorepersists vectors with the standard-librarysqlite3module and scans every stored vector on each query. It serializes access to its connection, so concurrent records can share one store, and each write is a single transaction. A stored vector holding NaN or infinity never appears in results. -
Custom stores. Subclasses of
AsyncEmbeddingStoreimplementadd,delete_record,query, andcount.replace_recorddefaults to delete then add; override it if your backend can do both atomically. -
Local embeddings.
AsyncSentenceTransformerEmbedder("all-MiniLM-L6-v2")embeds without network calls. It needs theembeddings-localextra, loads the model when constructed, and accepts a preloadedmodel=. -
Relevance without an LLM.
AsyncEmbeddingRelevanceFilterkeeps a record when its title and abstract are close enough to any reference text:from sci_etl_core import AsyncEmbeddingRelevanceFilter relevance_filter = AsyncEmbeddingRelevanceFilter( embedder=embedder, reference_texts=["ultra-diffuse galaxies", "low surface brightness galaxies"], threshold=0.35, # minimum cosine similarity )
State, Resuming, and Errors
Two state backends ship with the library:
AsyncFileStateManager(processed_ids_file, metadata_file)stores one processed id per line plus a JSON metadata file. It holds OS-level file locks and writes metadata atomically.AsyncSqliteStateManager(database_path)uses a WAL-mode SQLite database. Add it tocloseablesso its connection is closed. Itsflush()checkpoints the WAL.
Each run starts at the saved last_start_index and skips ids that were
already processed. The offset is saved after every page, but it only moves
past a page once every record on it is settled, meaning processed or marked
irrelevant. When a record fails, or is left over because total_limit was
reached, the offset stays at the start of that page for the rest of the run,
so the next run revisits it while skipping everything already processed.
Records are exported before they are marked processed, so a crash between the
two re-exports that record on the next run. AsyncCsvUpsertExporter absorbs
this; an appending exporter of your own should tolerate duplicates.
AsyncFileStateManager raises OSError when a state file exists but can't be
read, rather than treating it as empty. It rejects record ids that contain a
line boundary or have leading or trailing whitespace, since neither would read
back unchanged. Metadata content that isn't valid falls back to offset 0, which
only costs a rescan. Both backends record last_run_at as an ISO 8601
timestamp in UTC.
Newest-first listings. The arXiv extractor lists the newest submissions first, so new papers push older ones to higher offsets. A run that resumes from the saved offset keeps working backwards through older papers and does not revisit the new ones. To pick those up, pass
start_index=0: processed records are skipped by id, so a rescan costs listing requests (each preceded by the extractor'ssleep_before_searchdelay) but reprocesses nothing.
| Situation | Behavior |
|---|---|
| Listing request still fails after retries | run() raises PipelineAborted (cause: UpstreamError) |
Source rejects the listing request, e.g. arXiv answers 400 |
run() raises PipelineAborted (cause: ExtractionError) |
| Listing payload can't be parsed | run() raises PipelineAborted (cause: MalformedResponseError) |
| Listing is valid but has no entries | run() returns the count normally |
| Listing page holds only already-processed records | paging continues with the next page |
| One record raises, e.g. a transient full-text failure | logged through logger; record left unmarked; saved offset held at its page; other records continue |
| Records on one page fail and none on it is processed, but a later page processes a record | paging continues; the failed records stay unmarked for the next run |
| Records fail with none processed on a second page before any progress, or on the last page of the listing, e.g. a rejected API key or an unreadable CSV | run() raises PipelineAborted (cause: the last record's error) |
| arXiv reports the LaTeX and PDF as unavailable (e.g. 404), or neither can be parsed | full text falls back to the abstract |
arXiv serves a single gzipped .tex file or a PDF as the e-print |
the TeX is read, or the PDF is used instead |
LLM call fails inside AsyncLLMRelevanceFilter, or its verdict is unclear |
returns default_on_error (True) |
| Record has an empty abstract | relevance filters return default_on_empty_abstract (True) |
LLM call fails inside AsyncLLMEntityExtractor, or its entity list is malformed |
LLMError propagates: logged, record left unmarked and retried on the next run |
Record has a missing or blank record_id |
skipped and logged, since it can't be tracked as processed |
All library exceptions derive from SciEtlError: ExtractionError
(UpstreamError, MalformedResponseError), ParsingError, LLMError,
EmbeddingError, EmbeddingStoreError, ConfigurationError, and
PipelineAborted. All of them can be imported from sci_etl_core. The bundled
parsers raise ParsingError for bytes they can't read; a custom Parser
should do the same, so AsyncArxivExtractor moves on to its next source
instead of failing the record.
Graceful Shutdown
ShutdownSignal turns SIGINT/SIGTERM into a flag you can await. The pipeline
doesn't use it on its own, so run the pipeline as a task alongside it:
import asyncio
from sci_etl_core.signals import ShutdownSignal
async def run_until_signalled(pipeline, state_manager, **run_kwargs):
shutdown = ShutdownSignal(logger=print)
with shutdown.guard():
run = asyncio.create_task(pipeline.run(**run_kwargs))
stop = asyncio.create_task(shutdown.wait())
done, _ = await asyncio.wait({run, stop}, return_when=asyncio.FIRST_COMPLETED)
for task in (run, stop):
if task not in done:
task.cancel()
await asyncio.gather(run, stop, return_exceptions=True)
await state_manager.flush()
return None if run.cancelled() else run.result()
- First signal: sets the flag. Cancelled in-flight records stay unmarked and are retried on the next run.
- Second signal: restores the previous handler and terminates immediately.
- Where it works: call
guard()from async code on the main thread; on any other thread it logs a message and installs nothing. That means it can't be used throughETLPipeline, whose loop runs on a background thread.
Rate Limiting
AsyncETLPipeline(max_concurrency=...) bounds how many records are processed
at once. For finer control, sci_etl_core.rate_limiter provides standalone
async context managers:
SemaphoreRateLimiter— concurrency capAioLimiterRateLimiter— token bucket; needs theasyncextraNullRateLimiter— no limit
build_rate_limiter(max_concurrency, max_rate, time_period) returns a token
bucket when max_rate is set and a semaphore otherwise. Its parameters match
the full_text config section. Components don't take a limiter argument, so
apply one by wrapping a component:
from sci_etl_core.extractors import AsyncExtractor
from sci_etl_core.rate_limiter import AsyncRateLimiter, build_rate_limiter
class RateLimitedExtractor(AsyncExtractor):
"""Route an extractor's network calls through one shared limiter."""
def __init__(self, inner: AsyncExtractor, limiter: AsyncRateLimiter) -> None:
self._inner = inner
self._limiter = limiter
async def search(self, query, max_results, start_index):
async with self._limiter:
return await self._inner.search(query, max_results, start_index)
def parse_listing(self, raw_listing, seen_ids):
return self._inner.parse_listing(raw_listing, seen_ids)
async def fetch_full_text(self, record):
async with self._limiter:
return await self._inner.fetch_full_text(record)
# extractor = RateLimitedExtractor(AsyncArxivExtractor(...), build_rate_limiter(**config.full_text.model_dump()))
Synchronous Components
To reuse blocking implementations, subclass the synchronous interfaces and wrap each object in its adapter. The result can be passed to either pipeline:
| Sync interface | Adapter | Where the calls run |
|---|---|---|
Extractor |
SyncExtractorAdapter |
fetch_full_text in a worker thread; search and parse_listing on the event loop, between pages |
RelevanceFilter |
SyncRelevanceFilterAdapter |
worker thread |
EntityExtractor |
SyncEntityExtractorAdapter |
worker thread |
LLMClient |
SyncLLMClientAdapter |
worker thread |
Exporter |
SyncExporterAdapter |
on the event loop, so exports never interleave |
StateManager |
SyncStateManagerAdapter |
on the event loop, so updates are never lost |
import json
from sci_etl_core import Exporter, SyncExporterAdapter
class JsonLinesExporter(Exporter):
def export(self, data, destination):
with open(destination, "a", encoding="utf-8") as handle:
for row in data:
handle.write(json.dumps(row) + "\n")
exporter = SyncExporterAdapter(JsonLinesExporter())
# AsyncETLPipeline(..., exporter=exporter)
Calls that run on the event loop block it while they run: a slow exporter or state manager stalls every record in flight. Keep those calls fast, or implement the async interface instead.
Logging
configure_logging(name, log_file, level=logging.INFO) returns a
logging.Logger that writes to a file and to stdout, creating the file's
folder if it doesn't exist. Calling it again with the same name, file, and
level returns the same logger; a different file or level replaces the handlers
it installed. Components take a plain logger callable, so pass a bound
method:
from sci_etl_core import configure_logging
log = configure_logging("my_pipeline", "pipeline.log")
# AsyncETLPipeline(..., logger=log.warning)
# AsyncArxivExtractor(..., logger=log.info)
Architecture
ETLPipeline (blocking) --runs on a background event loop--> AsyncETLPipeline.run(query)
|
+---------------------------------------------------------------+
v
AsyncExtractor.search --> parse_listing --> records not yet in AsyncStateManager
| (max_concurrency at a time)
v
AsyncRelevanceFilter.is_relevant --no--> mark processed
| yes
v
AsyncExtractor.fetch_full_text (LaTeX -> PDF -> abstract)
|
+--> AsyncChunkIngestor (optional)
| TextChunker -> AsyncEmbedder
| -> AsyncEmbeddingStore
v
AsyncEntityExtractor.extract
|
v
AsyncExporter.export(entities, destination) --> mark processed
After each page: AsyncStateManager.save_metadata(last_start_index)
| Layer | Interface | Implementations | Import from |
|---|---|---|---|
| Extract | AsyncExtractor |
AsyncArxivExtractor |
sci_etl_core.extractors |
| Parse | Parser, TableParser |
PdfPlumberParser, LatexTarballParser, HtmlTextParser |
sci_etl_core.parsers |
| LLM | AsyncLLMClient |
AsyncOpenAICompatibleClient |
sci_etl_core.llm |
| Relevance | AsyncRelevanceFilter |
AsyncLLMRelevanceFilter, AsyncEmbeddingRelevanceFilter |
sci_etl_core.llm |
| Entities | AsyncEntityExtractor |
AsyncLLMEntityExtractor |
sci_etl_core.llm |
| Export | AsyncExporter |
AsyncCsvUpsertExporter (list of dicts); AsyncSqlTableExporter, AsyncPlotly3DExporter (DataFrame) |
sci_etl_core.exporters |
| State | AsyncStateManager |
AsyncFileStateManager, AsyncSqliteStateManager |
sci_etl_core.state |
| Embeddings | AsyncEmbedder |
AsyncOpenAIEmbedder, AsyncSentenceTransformerEmbedder |
sci_etl_core.embeddings |
| Chunking | TextChunker |
SlidingWindowChunker |
sci_etl_core.embeddings |
| Vector memory | AsyncEmbeddingStore |
InMemoryEmbeddingStore, AsyncSqliteEmbeddingStore |
sci_etl_core.embeddings |
| Post-processing | Processor, RecordValidator |
ProcessorChain, NormalizationStep, DeduplicationStep, ClusteringStep, CompletenessStep, QualityFlagStep; NumericRangeValidator, KeywordExclusionValidator, CompositeValidator |
sci_etl_core.processors |
| Sync adapters | Extractor, RelevanceFilter, EntityExtractor, LLMClient, Exporter, StateManager |
Sync*Adapter for each |
sci_etl_core |
| Orchestration | — | AsyncETLPipeline, ETLPipeline |
sci_etl_core |
The pipelines, stage interfaces, adapters, and most implementations are also
re-exported from sci_etl_core itself; parser implementations, processor
steps, and validators come from their subpackages. Supporting modules:
sci_etl_core.config, sci_etl_core.http_async (build_async_client),
sci_etl_core.rate_limiter, sci_etl_core.signals, sci_etl_core.log_utils,
and sci_etl_core.exceptions. Every package loads its public names on first
access, so importing one component never requires another component's
optional dependencies.
Testing
pip install -e ".[full,dev]"
pytest # full suite
pytest --cov=sci_etl_core --cov-report=term-missing # with coverage
The suite runs offline: HTTP, LLM, and embedding calls are mocked, and a stub
replaces SQLAlchemy when it isn't installed. Hypothesis property tests and ABC
conformance tests guard the public interfaces, and pytest --cov fails if line
coverage drops below 100%.
Contributing
Contributions are welcome — new extractors, parsers, exporters, and embedding backends especially. See CONTRIBUTING.md to get set up, and browse good first issues if you're new. Moving an existing pipeline onto the library? See MIGRATION.md. All participation is governed by our Code of Conduct.
Security
Please report vulnerabilities privately — see SECURITY.md.
License
Released under the MIT License. See LICENSE for details.
Release files for sci-etl-core 0.1.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| sci_etl_core-0.1.2.tar.gz | 112.2 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| sci_etl_core-0.1.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 198.0 kB
Release files / sci_etl_core-0.1.2.tar.gz
| Download URL | sci_etl_core-0.1.2.tar.gz |
|---|---|
| Size | 112.2 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
40a12880e8e51fceae40e14de1f2ff53fc64f0f299159424a8c066588cc8b567
|
|
BLAKE2b-256 checksum How to use checksums |
2967b7de4c9d79d064556e7b9685f40ac4d94ab1031ac4709b923089e39d6f21
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 14, 2026.
Transparency logRelease files / sci_etl_core-0.1.2-py3-none-any.whl
| Download URL | sci_etl_core-0.1.2-py3-none-any.whl |
|---|---|
| Size | 85.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
3a3909a700680884e4315a251ff3e85b78a4f3dc10005ac839b0d9f672b5ae75
|
|
BLAKE2b-256 checksum How to use checksums |
d7fd776289e62b8910521865613fc43727635422f7e7709b7b8606f45b06e7b2
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 14, 2026.
Transparency log