Every public name in scopusflow is documented here, grouped along the path a
search takes: describe it, size it, run it as a resumable harvest, normalise
what comes back, see what a later retrieval changed, and summarise and plot the
result. The groups are the ones the
R package's reference index
uses, and they come in the same order, so a function documented in both
languages is filed under the same heading on either site.
Two of the group names differ from the R ones because the membership differs.
The R group 'Plan and size' also holds the counting function, which is
documented under Retrieve here alongside the other calls that spend quota, so
the first group is called 'Plan and query' instead. Records is wider than its R
namesake, because it absorbs the R groups 'Export and I/O' and 'Data'. The
BibTeX and RIS writers and the bundled example harvest all work on the record
table, and neither would fill a section of its own. Reporting holds the same one
function in both languages. The two remaining R groups have no counterpart
below. 'App' documents a function that launches the
interface, where the Python app is started from the command line and covered by
The code-free app, and 'Keys' documents a key check that
scopusflow does not need in Python, since pybliometrics holds the key
configuration.
Every name listed under a group heading is importable straight from
scopusflow, conventionally as sf. The guides linked from the
home page work the same functions through end-to-end examples.
Combine terms into one Scopus query, optionally field-wrapping each.
Parameters:
Name
Type
Description
Default
*terms
str
One or more non-empty search terms.
()
op
str
The boolean operator joining the terms: "AND", "OR" or "AND NOT".
'AND'
field
str | None
An optional field tag applied to every term (see :data:FIELD_TAGS).
None
Source code in src/scopusflow/query.py
363738394041424344454647484950515253
defscopus_query(*terms:str,op:str="AND",field:str|None=None)->str:"""Combine terms into one Scopus query, optionally field-wrapping each. Parameters ---------- *terms: One or more non-empty search terms. op: The boolean operator joining the terms: ``"AND"``, ``"OR"`` or ``"AND NOT"``. field: An optional field tag applied to every term (see :data:`FIELD_TAGS`). """ifopnotin{"AND","OR","AND NOT"}:raiseValueError("op must be one of 'AND', 'OR', 'AND NOT'.")cleaned=[t.strip()fortinterms]ifnotcleanedorany(nottfortincleaned):raiseValueError("All terms must be non-empty.")returnf" {op} ".join(wrap_field(t,field)fortincleaned)
Wrap query in a field tag, e.g. TITLE-ABS-KEY(graphene).
Source code in src/scopusflow/query.py
2627282930313233
defwrap_field(query:str,field:str|None)->str:"""Wrap ``query`` in a field tag, e.g. ``TITLE-ABS-KEY(graphene)``."""iffieldisNone:returnqueryfield=field.strip().upper()ifnot_FIELD_RE.match(field):raiseValueError(f"Invalid field tag {field!r}; use letters and hyphens only.")returnf"{field}({query})"
FIELD_TAGS={'TITLE':'Words in the document title','TITLE-ABS-KEY':'Title, abstract and keywords','TITLE-ABS-KEY-AUTH':'Title, abstract, keywords and author names','ABS':'Abstract text','KEY':'Indexed and author keywords','AUTH':'Author names','AUTHKEY':'Author-supplied keywords','AFFIL':'Affiliation, any part','AFFILORG':'Affiliation organisation name','SRCTITLE':'Source (publication) title','DOI':'Digital Object Identifier','ALL':'All available fields'}
A fully specified, inspectable description of a Scopus search.
Splitting describing a search from executing it makes a workflow
reproducible and lets a large retrieval be partitioned by year, so it can be
cached and resumed.
page_size is the number of records asked for per request, None
(the default) meaning the largest the view allows: 200 for STANDARD,
25 for COMPLETE. It is stored on the plan, and sent, because the search
record :func:scopusflow.report.scopus_search_report writes has to state
how the harvest was paged, and a figure taken from anywhere but the plan
would be a guess.
@dataclass(frozen=True)classSearchPlan:"""A fully specified, inspectable description of a Scopus search. Splitting *describing* a search from *executing* it makes a workflow reproducible and lets a large retrieval be partitioned by year, so it can be cached and resumed. ``page_size`` is the number of records asked for per request, ``None`` (the default) meaning the largest the view allows: 200 for ``STANDARD``, 25 for ``COMPLETE``. It is stored on the plan, and sent, because the search record :func:`scopusflow.report.scopus_search_report` writes has to state how the harvest was paged, and a figure taken from anywhere but the plan would be a guess. """query:stryears:Sequence[int]|None=Nonefield:str|None=Noneview:str="STANDARD"partition:str="none"# "none" or "year"page_size:int|None=Nonedef__post_init__(self)->None:ifnotself.queryornotself.query.strip():raiseValueError("query must be a non-empty string.")ifself.viewnotin{"STANDARD","COMPLETE"}:raiseValueError("view must be 'STANDARD' or 'COMPLETE'.")ifself.partitionnotin{"none","year"}:raiseValueError("partition must be 'none' or 'year'.")object.__setattr__(self,"page_size",_check_page_size(self.page_size,self.view))# Store the validated integers, never the values as passed: cells() renders the# year into the cell's date, and str(2015.0) would reach the API as# "2015.0". A tuple, since a list would leave the frozen dataclass unhashable.# object.__setattr__ is how a frozen dataclass normalises a field.## Sorted and de-duplicated, as every other caller of _check_years already# does with its result and as cells() does with this one, so that the# stored years say what the search will actually do. Without it two plans# describing the same search compared unequal on the order the years# happened to be typed in, and the reproduction snippet# scopus_search_report() emits (which renders them canonically) rebuilt a# plan that ran identically but failed an equality check against its own# original.checked=_check_years(self.years)object.__setattr__(self,"years",NoneifcheckedisNoneelsetuple(sorted(set(checked))))ifself.partition=="year"andnotself.years:raiseValueError("partition='year' requires years.")@propertydefwrapped_query(self)->str:returnwrap_field(self.query,self.field)defcells(self)->list[PlanCell]:"""Expand the plan into the cells that will be fetched."""q=self.wrapped_querysize=int(self.page_size)# type: ignore[arg-type]ifself.partition=="year":years=sorted(set(self.years))# type: ignore[arg-type]return[PlanCell(i+1,q,str(y),y,self.view,size)fori,yinenumerate(years)]date=Noneifself.years:lo,hi=min(self.years),max(self.years)date=str(lo)iflo==hielsef"{lo}-{hi}"return[PlanCell(1,q,date,None,self.view,size)]
Expand the plan into the cells that will be fetched.
Source code in src/scopusflow/plan.py
139140141142143144145146147148149150151152153
defcells(self)->list[PlanCell]:"""Expand the plan into the cells that will be fetched."""q=self.wrapped_querysize=int(self.page_size)# type: ignore[arg-type]ifself.partition=="year":years=sorted(set(self.years))# type: ignore[arg-type]return[PlanCell(i+1,q,str(y),y,self.view,size)fori,yinenumerate(years)]date=Noneifself.years:lo,hi=min(self.years),max(self.years)date=str(lo)iflo==hielsef"{lo}-{hi}"return[PlanCell(1,q,date,None,self.view,size)]
@dataclass(frozen=True)classPlanCell:"""One unit of work in a :class:`SearchPlan`."""cell:intquery:strdate:str|Noneyear:int|Noneview:strpage_size:int=200
Return how many records the (optionally year-filtered) query matches.
A single cheap request that does not download the records, so it is the right
way to size a search before committing quota to a harvest.
Source code in src/scopusflow/count.py
262728293031323334353637383940
defscopus_count(query:str,years:Sequence[int]|None=None,field:str|None=None,view:str="STANDARD",**kwargs)->int:"""Return how many records the (optionally year-filtered) query matches. A single cheap request that does not download the records, so it is the right way to size a search before committing quota to a harvest. """ifnotqueryornotstr(query).strip():raiseValueError("query must be a non-empty string.")q=_count_query(str(query).strip(),years,field)frompybliometrics.scopusimportScopusSearch# imported lazily; needs a keyreturnint(ScopusSearch(q,view=view,download=False,**kwargs).get_results_size())
Run every cell of plan and return one normalised DataFrame.
With cache_dir set, each cell is written to disk as it completes, so an
interrupted or quota-limited run resumes without re-fetching finished cells.
A cache_dir belongs to one plan: checkpoints are keyed by cell number, so
on resume each checkpoint's own recorded query and view are compared
against the cell's, and a checkpoint written by a different plan is warned
about and refetched, never silently returned. Point each plan at its own
directory. A checkpoint that cannot be read back is likewise treated as a
miss, warned about and refetched, and the harvest carries on.
format selects the checkpoint format ("parquet" or "csv"); parquet
silently falls back to CSV when no parquet engine is installed. Pass a
zero-argument should_stop callable to allow co-operative cancellation: it
is checked before each cell and the harvest stops (returning what it has) when
it returns True. Per-cell progress is emitted on the "scopusflow"
logger.
Each cell's row count is compared against the total the API reports for
that cell's query, and a shortfall is warned about, since a truncated or
failed download otherwise arrives as a merely small result. The per-cell
accounting is attached as result.attrs["cell_totals"], a frame of
cell, date, n_records and reported_total, and their sum as
result.attrs["total_results"], the attribute the R twin's
scopus_fetch() also attaches. The sum is None unless every cell
reported a total, since a partial sum would understate the search while
looking like a real figure; a cell resumed from a checkpoint reports none,
the count not being part of what a checkpoint stores.
The harvest also carries its provenance: the originating plan,
retrieved_at (a timezone-aware UTC datetime),
scopusflow_version and paging. These are what
:func:scopusflow.report.scopus_search_report reads back, and they are
omitted, never approximated, when any cell was resumed from a
checkpoint, since a checkpoint carries no record of when it was taken and
dating the whole from the cells that were fetched now would date it later
than part of what it holds.
When plan.view == "COMPLETE", the output gains an authkeywords
column (see :func:scopusflow.records.to_records) at no extra request cost
beyond COMPLETE's own smaller page size, which already means more
requests, and so more quota, for the same number of records. A plan with
view="STANDARD" (the default) never carries this column, so existing
code is unaffected: a checkpoint written under the other view is refetched
like any other different-plan checkpoint. Resuming a cache written before
this column existed is safe in the COMPLETE direction:
pandas.concat fills the older cells' missing column with NA rather
than erroring.
deffetch_plan(plan:SearchPlan,cache_dir:str|None=None,resume:bool=True,format:str="parquet",should_stop=None,**kwargs,)->pd.DataFrame:"""Run every cell of ``plan`` and return one normalised DataFrame. With ``cache_dir`` set, each cell is written to disk as it completes, so an interrupted or quota-limited run resumes without re-fetching finished cells. A cache_dir belongs to one plan: checkpoints are keyed by cell number, so on resume each checkpoint's own recorded query and view are compared against the cell's, and a checkpoint written by a different plan is warned about and refetched, never silently returned. Point each plan at its own directory. A checkpoint that cannot be read back is likewise treated as a miss, warned about and refetched, and the harvest carries on. ``format`` selects the checkpoint format ("parquet" or "csv"); parquet silently falls back to CSV when no parquet engine is installed. Pass a zero-argument ``should_stop`` callable to allow co-operative cancellation: it is checked before each cell and the harvest stops (returning what it has) when it returns ``True``. Per-cell progress is emitted on the ``"scopusflow"`` logger. Each cell's row count is compared against the total the API reports for that cell's query, and a shortfall is warned about, since a truncated or failed download otherwise arrives as a merely small result. The per-cell accounting is attached as ``result.attrs["cell_totals"]``, a frame of ``cell``, ``date``, ``n_records`` and ``reported_total``, and their sum as ``result.attrs["total_results"]``, the attribute the R twin's ``scopus_fetch()`` also attaches. The sum is ``None`` unless every cell reported a total, since a partial sum would understate the search while looking like a real figure; a cell resumed from a checkpoint reports none, the count not being part of what a checkpoint stores. The harvest also carries its provenance: the originating ``plan``, ``retrieved_at`` (a timezone-aware UTC ``datetime``), ``scopusflow_version`` and ``paging``. These are what :func:`scopusflow.report.scopus_search_report` reads back, and they are omitted, never approximated, when any cell was resumed from a checkpoint, since a checkpoint carries no record of when it was taken and dating the whole from the cells that were fetched now would date it later than part of what it holds. When ``plan.view == "COMPLETE"``, the output gains an ``authkeywords`` column (see :func:`scopusflow.records.to_records`) at no extra request cost beyond ``COMPLETE``'s own smaller page size, which already means more requests, and so more quota, for the same number of records. A plan with ``view="STANDARD"`` (the default) never carries this column, so existing code is unaffected: a checkpoint written under the other view is refetched like any other different-plan checkpoint. Resuming a cache written before this column existed is safe in the ``COMPLETE`` direction: ``pandas.concat`` fills the older cells' missing column with ``NA`` rather than erroring. """ifnotisinstance(plan,SearchPlan):raiseValueError("plan must be a SearchPlan.")ifformatnotin_FORMATS:raiseValueError("format must be 'parquet' or 'csv'.")frompybliometrics.scopusimportScopusSearch# imported lazily; needs a keycache=Path(cache_dir)ifcache_direlseNoneifcacheisnotNone:cache.mkdir(parents=True,exist_ok=True)cells=plan.cells()total=len(cells)frames:list[pd.DataFrame]=[]accounting:list[dict]=[]stamps:list[datetime|None]=[]forcellincells:ifshould_stopisnotNoneandshould_stop():logger.info("Stopped before cell %d/%d.",cell.cell,total)breakquery=_cell_query(cell.query,cell.year,cell.date)ifcacheisnotNoneandresume:existing=_find_checkpoint(cache,cell.cell)cached=_read_checkpoint(existing)ifexistingisnotNoneelseNoneifexistingisnotNoneandcachedisNone:warnings.warn(f"The checkpoint {existing} could not be read back, so it ""was discarded and the cell refetched. An interrupted run ""can leave a checkpoint half-written.",stacklevel=2,)elifcachedisnotNone:# Checkpoints are keyed by cell number alone, so a cache_dir# reused for a different plan would otherwise hand back the# wrong records silently. The frames carry the query they were# fetched with; a mismatch means the checkpoint belongs to# another plan and the cell is refetched. A zero-row checkpoint# carries no query values to compare and is accepted as is.# The view is compared too, since the query alone cannot tell# a STANDARD plan from a COMPLETE one, and a COMPLETE-written# checkpoint would hand a STANDARD resume an authkeywords# column the documentation promises it never carries.cached_queries=(set(cached["query"].dropna().unique())if"query"incached.columnselseset())cached_view=_checkpoint_view(cached)ifcached_queriesandcached_queries!={query}:warnings.warn(f"Checkpoint for cell {cell.cell} in {cache} was written "f"by a different plan (query {sorted(cached_queries)!r}, "f"not {query!r}); refetching this cell. Use one cache_dir ""per plan.",stacklevel=2,)elifcached_viewisnotNoneandcached_view!=cell.view:warnings.warn(f"Checkpoint for cell {cell.cell} in {cache} was written "f"by a different plan (view {cached_view!r}, "f"not {cell.view!r}); refetching this cell. Use one ""cache_dir per plan.",stacklevel=2,)else:logger.info("Cell %d/%d: loaded from cache.",cell.cell,total)served=cached.drop(columns=["view"],errors="ignore")frames.append(served)accounting.append({"cell":cell.cell,"date":cell.date,"n_records":len(served),"reported_total":None})stamps.append(None)continuelogger.info("Cell %d/%d: fetching %s",cell.cell,total,query)# The page size is the plan's, so the harvest pages the way the plan (and# the search record built from it) says it does. setdefault, so a caller# passing count of their own still wins.kwargs.setdefault("count",cell.page_size)search=ScopusSearch(query,view=cell.view,cursor=True,**kwargs)frame=to_records(search.results,query=query,view=cell.view)# A download that returned nothing yields a zero-row frame and never# an error, so without this comparison a truncated or failed cell is# indistinguishable from one that matched only a few records.cell_total=_reported_total(search)accounting.append({"cell":cell.cell,"date":cell.date,"n_records":len(frame),"reported_total":cell_total})stamps.append(datetime.now(timezone.utc))ifcell_totalisnotNone:iflen(frame)<cell_total:warnings.warn(f"Cell {cell.cell} retrieved {len(frame)} record(s), but the "f"Scopus API reports {cell_total} for this query, so the ""harvest may be incomplete. Check the key's remaining quota, ""and consider partitioning the plan by year so each cell is ""smaller.",stacklevel=2,)ifcacheisnotNone:# The view travels with the checkpoint (and only the checkpoint;# resume strips it again) so a resume under the other view is# detectable in both directions, beyond the case where an authkeywords# column betrays a COMPLETE origin._write_checkpoint(frame.assign(view=cell.view),cache,cell.cell,format)frames.append(frame)ifnotframes:columns=[*RECORD_COLUMNS,"authkeywords"]ifplan.view=="COMPLETE"elseRECORD_COLUMNSout=pd.DataFrame(columns=columns)else:out=pd.concat(frames,ignore_index=True)out["entry_number"]=range(1,len(out)+1)logger.info("Retrieved %d records.",len(out))# The plan travels with its own harvest, as it does in the R twin, so the# search record can be written from the records alone.out.attrs["plan"]=planout.attrs["cell_totals"]=pd.DataFrame(accounting,columns=["cell","date","n_records","reported_total"])reported=[row["reported_total"]forrowinaccounting]out.attrs["total_results"]=(sum(reported)ifaccountingandall(nisnotNoneforninreported)elseNone)out.attrs["paging"]="cursor"ifstampsandall(sisnotNoneforsinstamps):# Imported inside the function, and never at module scope: this module# is imported# while the package's own __init__ is still executing, and __version__# is not bound until after that import returns.from.import__version__out.attrs["retrieved_at"]=min(stamps)out.attrs["scopusflow_version"]=__version__returnout
Retrieve abstracts for one or many ids, resilient per id.
by selects the lookup type ("doi", "eid" or "scopus_id"). Any id that
fails is warned about and yields an all-NA row that still records the id.
view defaults to "META_ABS" (pybliometrics' own default), which
carries the abstract text; the lighter "META" omits it and leaves the
abstract column empty. include, when unused, leaves the column
set exactly as before.
include names extra fields to retrieve in the same request:
"references" and/or "keywords". Both require Abstract Retrieval's "FULL"
or "REF" view (see view), an entitlement separate from ordinary
abstract access and from Scopus Search access, and that, per Elsevier's
own documentation, some fields (notably author keywords) may need to be
requested from your Scopus/Elsevier account contact even when the view
itself is otherwise accessible. In development, against a live key with
full Abstract Retrieval access, "FULL" returned a complete, correctly
counted reference list for every document tried, while "REF" returned the
identical, complete list in one case but a truncated (paginated) subset in
another on an otherwise identical request made moments apart; "FULL" is
recommended when your entitlement allows it, and a mismatch between the
number of references returned and the document's own reported reference
count (refcount) is warned about, since the list may be an incomplete
page of the bibliography.
When "keywords" is included, an authkeywords column is added: the
document's author-supplied keywords, joined with "; ", or NA when the
document has none, or when the API omits the field for a given key's
entitlement. In this package's own development testing, against a live,
otherwise fully-entitled key, this field did not populate for documents
that do carry author keywords in Scopus itself, so an all-NA column
is more likely an entitlement gap worth raising with your Scopus/Elsevier
account contact than genuinely absent data.
When "references" is included, a references column is added: one
DataFrame per document (one row per cited work), using pybliometrics' own
native field set (position, id, doi, title, authors,
authors_auid, authors_affiliationid, sourcetitle,
publicationyear, coverDate, volume, issue, first,
last, citedbycount, type, text, fulltext). A document
with no resolvable references yields a zero-row DataFrame, so
the column can always be unnested.
cache_dir and resume checkpoint per identifier, the way
:func:scopusflow.fetch.fetch_plan checkpoints per cell, pickled rather
than written as parquet/csv, since a row here can carry a nested
DataFrame. Only a successful retrieval is checkpointed: a failed
identifier still yields its warned-about NA row in the returned frame,
but is retried on the next resumed run, never read back as data.
Worth setting whenever include is used: Abstract Retrieval
draws on its own weekly quota, smaller than and separate from Search's,
and every identifier costs its own request, so re-running an interrupted
batch without a cache re-spends quota already spent. Relying on
pybliometrics' own on-disk response cache (its refresh parameter,
keyed by identifier and view under its configured cache directory) is
enough to avoid repeat network calls for the same identifier across
script runs; this checkpoint is for batch-level progress and resumability
across many identifiers, a separate concern.
The number of Abstract Retrieval requests made, and the most recently
parsed remaining-quota figure (from pybliometrics'
get_key_remaining_quota()), are attached as result.attrs["n_requests"]
and result.attrs["quota"], since this is a materially more expensive
operation than a search call.
A 403 (an entitlement gate, most often on the requested view or field)
raises :class:scopusflow.exceptions.ScopusFlowForbiddenError and stops
the batch immediately, naming the view and identifier, where a generic
failure would leave the caller guessing. Repeating the identical failure
for every remaining identifier would serve nobody: entitlement is a
property of the account, so a retry cannot succeed.
defscopus_abstract(ids,by:str="doi",view:str="META_ABS",include:tuple[str,...]=(),cache_dir:str|None=None,resume:bool=True,**kwargs,)->pd.DataFrame:"""Retrieve abstracts for one or many ids, resilient per id. ``by`` selects the lookup type ("doi", "eid" or "scopus_id"). Any id that fails is warned about and yields an all-NA row that still records the id. ``view`` defaults to "META_ABS" (pybliometrics' own default), which carries the abstract text; the lighter "META" omits it and leaves the ``abstract`` column empty. ``include``, when unused, leaves the column set exactly as before. ``include`` names extra fields to retrieve in the same request: "references" and/or "keywords". Both require Abstract Retrieval's "FULL" or "REF" view (see ``view``), an entitlement separate from ordinary abstract access and from Scopus Search access, and that, per Elsevier's own documentation, some fields (notably author keywords) may need to be requested from your Scopus/Elsevier account contact even when the view itself is otherwise accessible. In development, against a live key with full Abstract Retrieval access, "FULL" returned a complete, correctly counted reference list for every document tried, while "REF" returned the identical, complete list in one case but a truncated (paginated) subset in another on an otherwise identical request made moments apart; "FULL" is recommended when your entitlement allows it, and a mismatch between the number of references returned and the document's own reported reference count (``refcount``) is warned about, since the list may be an incomplete page of the bibliography. When "keywords" is included, an ``authkeywords`` column is added: the document's author-supplied keywords, joined with "; ", or ``NA`` when the document has none, or when the API omits the field for a given key's entitlement. In this package's own development testing, against a live, otherwise fully-entitled key, this field did not populate for documents that do carry author keywords in Scopus itself, so an all-``NA`` column is more likely an entitlement gap worth raising with your Scopus/Elsevier account contact than genuinely absent data. When "references" is included, a ``references`` column is added: one DataFrame per document (one row per cited work), using pybliometrics' own native field set (``position``, ``id``, ``doi``, ``title``, ``authors``, ``authors_auid``, ``authors_affiliationid``, ``sourcetitle``, ``publicationyear``, ``coverDate``, ``volume``, ``issue``, ``first``, ``last``, ``citedbycount``, ``type``, ``text``, ``fulltext``). A document with no resolvable references yields a zero-row DataFrame, so the column can always be unnested. ``cache_dir`` and ``resume`` checkpoint per identifier, the way :func:`scopusflow.fetch.fetch_plan` checkpoints per cell, pickled rather than written as parquet/csv, since a row here can carry a nested DataFrame. Only a successful retrieval is checkpointed: a failed identifier still yields its warned-about NA row in the returned frame, but is retried on the next resumed run, never read back as data. Worth setting whenever ``include`` is used: Abstract Retrieval draws on its own weekly quota, smaller than and separate from Search's, and every identifier costs its own request, so re-running an interrupted batch without a cache re-spends quota already spent. Relying on pybliometrics' own on-disk response cache (its ``refresh`` parameter, keyed by identifier and view under its configured cache directory) is enough to avoid repeat network calls for the *same* identifier across script runs; this checkpoint is for batch-level progress and resumability across *many* identifiers, a separate concern. The number of Abstract Retrieval requests made, and the most recently parsed remaining-quota figure (from pybliometrics' ``get_key_remaining_quota()``), are attached as ``result.attrs["n_requests"]`` and ``result.attrs["quota"]``, since this is a materially more expensive operation than a search call. A 403 (an entitlement gate, most often on the requested view or field) raises :class:`scopusflow.exceptions.ScopusFlowForbiddenError` and stops the batch immediately, naming the view and identifier, where a generic failure would leave the caller guessing. Repeating the identical failure for every remaining identifier would serve nobody: entitlement is a property of the account, so a retry cannot succeed. """ifbynotin_ID_TYPES:raiseValueError("by must be one of 'doi', 'eid', 'scopus_id'.")include=tuple(include)ifincludeelse()ifnotset(include)<=_KNOWN_INCLUDE:raiseValueError("include must be made up of 'references' and/or 'keywords'.")if"references"inincludeandviewnotin_VIEWS_WITH_REFERENCES:raiseValueError('include="references" needs view="FULL" or view="REF".')id_type=_ID_TYPES[by]id_column="doi"ifby=="doi"else"scopus_id"ifisinstance(ids,str):ids=[ids]columns=list(ABSTRACT_COLUMNS)if"keywords"ininclude:columns=[*columns,"authkeywords"]if"references"ininclude:columns=[*columns,"references"]cache=Path(cache_dir)ifcache_direlseNoneifcacheisnotNone:cache.mkdir(parents=True,exist_ok=True)# Resolve the dependency once: a missing pybliometrics is a setup error and# must be raised in its own right, where masquerading as every id failing# to retrieve would hide the account-level cause.frompybliometrics.scopusimportAbstractRetrieval# lazy; needs a keytry:# A separate, defensive import: pybliometrics.exception is an internal# module, outside pybliometrics.scopus's own public surface, so a# minimal test double or an unexpected future reorganisation should# degrade to generic exception handling below, which stops it breaking# every call to this function.frompybliometrics.exceptionimportScopus403ErrorexceptImportError:classScopus403Error(Exception):# never actually raised; see abovepassn_requests=0quota=Nonerows=[]fori,identinenumerate(ids,start=1):checkpoint=(_find_abstract_checkpoint(cache,view,include,ident)ifcacheisnotNoneelseNone)ifcheckpointisnotNoneandresume:cached=_read_abstract_checkpoint(checkpoint)ifcachedisNone:warnings.warn(f"The checkpoint {checkpoint} could not be read back, so it ""was discarded and the identifier retrieved again. An ""interrupted run can leave a checkpoint half-written.",UserWarning,stacklevel=2,)else:logger.info("%d/%d: %s loaded from cache.",i,len(ids),ident)rows.append(cached)continuelogger.info("Retrieving %d/%d: %s",i,len(ids),ident)try:ab=AbstractRetrieval(ident,id_type=id_type,view=view,**kwargs)n_requests+=1# Optional: an object without these methods (a plain dict, or a# minimal stand-in) simply reports no quota, and does not fail# the whole retrieval over a metadata nicety. Both lookups are# guarded, since an object carrying only the first would otherwise# fail here after a successful retrieval and record an NA row.get_quota=getattr(ab,"get_key_remaining_quota",None)get_reset=getattr(ab,"get_key_reset_time",None)remaining=get_quota()ifcallable(get_quota)elseNoneifremainingisnotNone:reset=get_reset()ifcallable(get_reset)elseNonequota={"remaining":remaining,"reset":reset}row=_abstract_row(ab,include=include)exceptScopus403Errorasexc:n_requests+=1remaining_ids=len(ids)-i# The FULL/REF alternative is only sensible advice when the failed# view was one of the two reference-carrying views; a 403 on a# plain META/META_ABS retrieval is an ordinary entitlement gap.alternative=(' or, if you have not already, try the other of "FULL"/"REF"'ifviewin_VIEWS_WITH_REFERENCESelse"")raiseScopusFlowForbiddenError(f'Abstract Retrieval refused view="{view}" (HTTP 403) for {ident!r}. '"This usually means your Scopus API key's entitlement does not cover ""the requested view or field; contact your Scopus/Elsevier account "f"holder or institutional administrator to request access{alternative}. ""Stopping rather "f"than repeating the same failure for the remaining {remaining_ids} ""identifier(s) (this entitlement is an account-level property, not a ""per-document one, so it will not succeed on retry).")fromexcexceptException:# one bad id must not sink the batchn_requests+=1warnings.warn(f"Could not retrieve abstract for {ident!r}; recording NA row.",stacklevel=2,)row={col:pd.NAforcolincolumns}row[id_column]=identif"references"ininclude:row["references"]=_references_frame(None)# Never checkpointed: many failures are transient (a timeout, a# quota 429, a 5xx), and a persisted NA row would be read back as# data on every later resume. The identifier is retried instead,# at the cost of one request.rows.append(row)continueifcacheisnotNone:_write_abstract_checkpoint(row,cache,view,include,ident)rows.append(row)out=pd.DataFrame(rows,columns=columns)out.attrs["n_requests"]=n_requestsout.attrs["quota"]=quotareturnout
Enrich records (from :func:scopusflow.fetch.fetch_plan) with author
keywords and structured references via Abstract Retrieval, returning a
minimal, uniform shape close to what OpenAlex's works API already
returns: id, title, year, keywords (a list of strings per
row) and references (a DataFrame of cited works per row). This is
meant for downstream tools that want to consume Scopus output without
writing their own parsing layer, for example for keyword co-occurrence or
citation-network analysis. It does not replace :func:to_bibtex/
:func:to_ris, which keep their own established interchange formats.
by selects which column of records ("doi" or "scopus_id") to look
identifiers up by. view is passed to :func:scopus_abstract and
defaults to "FULL", which in development returned a complete, correctly
counted reference list for every document tried, unlike "REF", which
returned an inconsistent, sometimes-truncated subset (see
:func:scopus_abstract's documentation for the entitlement each view
needs). cache_dir and resume are passed through unchanged, and are
worth setting for anything beyond a handful of records, since this
performs one Abstract Retrieval request per record, against its own,
smaller weekly quota, separate from Search's. What that cost came to is
carried through from :func:scopus_abstract: the number of requests made
and the most recently parsed remaining-quota figure are attached as
result.attrs["n_requests"] and result.attrs["quota"].
A record whose identifier is missing (NA/None) is dropped, with a
warning naming how many.
The keywords column here is list[str] per row, split out of
:func:scopus_abstract's joined authkeywords string, empty when the
document has none or the field is unavailable. references carries
pybliometrics' own native reference field set (see
:func:scopus_abstract's documentation).
defcorpus(records:pd.DataFrame,by:str="doi",view:str="FULL",cache_dir:str|None=None,resume:bool=True,**kwargs,)->pd.DataFrame:"""Enrich ``records`` (from :func:`scopusflow.fetch.fetch_plan`) with author keywords and structured references via Abstract Retrieval, returning a minimal, uniform shape close to what OpenAlex's ``works`` API already returns: ``id``, ``title``, ``year``, ``keywords`` (a list of strings per row) and ``references`` (a DataFrame of cited works per row). This is meant for downstream tools that want to consume Scopus output without writing their own parsing layer, for example for keyword co-occurrence or citation-network analysis. It does not replace :func:`to_bibtex`/ :func:`to_ris`, which keep their own established interchange formats. ``by`` selects which column of ``records`` ("doi" or "scopus_id") to look identifiers up by. ``view`` is passed to :func:`scopus_abstract` and defaults to "FULL", which in development returned a complete, correctly counted reference list for every document tried, unlike "REF", which returned an inconsistent, sometimes-truncated subset (see :func:`scopus_abstract`'s documentation for the entitlement each view needs). ``cache_dir`` and ``resume`` are passed through unchanged, and are worth setting for anything beyond a handful of records, since this performs one Abstract Retrieval request per record, against its own, smaller weekly quota, separate from Search's. What that cost came to is carried through from :func:`scopus_abstract`: the number of requests made and the most recently parsed remaining-quota figure are attached as ``result.attrs["n_requests"]`` and ``result.attrs["quota"]``. A record whose identifier is missing (``NA``/``None``) is dropped, with a warning naming how many. The `keywords` column here is `list[str]` per row, split out of :func:`scopus_abstract`'s joined `authkeywords` string, empty when the document has none or the field is unavailable. `references` carries pybliometrics' own native reference field set (see :func:`scopus_abstract`'s documentation). """required={by,"title","year"}ifnotrequired.issubset(records.columns):raiseValueError(f"records must have {sorted(required)} columns ""(as fetch_plan() returns).")ids=records[by]keep=ids.notna()n_dropped=int((~keep).sum())ifn_dropped:warnings.warn(f"Dropped {n_dropped} record(s) with no usable {by}.",stacklevel=2,)ifnotkeep.any():raiseValueError("records has no usable identifiers to look up.")records=records.loc[keep].reset_index(drop=True)ab=scopus_abstract(list(records[by]),by=by,view=view,include=("references","keywords"),cache_dir=cache_dir,resume=resume,**kwargs,)def_split(kw):ifpd.isna(kw):return[]return[k.strip()forkinkw.split(";")]out=pd.DataFrame({# The identifier used to look each record up, taken from `records`# directly, and never read back from `ab`: scopus_abstract() only# echoes the input identifier verbatim in its "doi"/"scopus_id"# column on a failed lookup (to keep the failing row identifiable);# on success that column holds whatever Scopus itself returns, which# is usually but not guaranteedly identical to the input."id":list(records[by]),"title":records["title"],"year":records["year"],"keywords":[_split(kw)forkwinab["authkeywords"]],"references":list(ab["references"]),})# A freshly constructed frame carries no attrs, so the wrapper that spends# the most quota would otherwise be the one reporting none of it.out.attrs.update(ab.attrs)returnout
Raised when the Scopus API refuses a request (HTTP 403), most often
because the configured key's entitlement does not cover the requested
Abstract Retrieval view or field.
Source code in src/scopusflow/exceptions.py
14151617
classScopusFlowForbiddenError(Exception):"""Raised when the Scopus API refuses a request (HTTP 403), most often because the configured key's entitlement does not cover the requested Abstract Retrieval view or field."""
Normalise a pybliometrics ScopusSearch().results list (named tuples)
or a list of dicts into a tidy :data:RECORD_COLUMNS DataFrame.
Whatever the query type, the columns are the same, so the downstream DOI,
diff and analysis helpers can rely on them.
When view="COMPLETE", an authkeywords column is added: the author-
supplied keywords the Scopus Search API returns under that view, as a
single string in Scopus's own " | "-delimited form (None when the
document has none, or when the API omits the field for a given key's
entitlement: this was observed directly against a live, otherwise
fully-entitled key during development, on documents that do carry author
keywords in Scopus itself, so an all-None column is more likely an
entitlement gap worth raising with your Scopus/Elsevier account contact
than genuinely absent data). Any other view, including the default
None, reproduces :data:RECORD_COLUMNS exactly, so existing callers
that never pass view see no change at all.
defto_records(results,query:str|None=None,view:str|None=None)->pd.DataFrame:"""Normalise a pybliometrics ``ScopusSearch().results`` list (named tuples) or a list of dicts into a tidy :data:`RECORD_COLUMNS` DataFrame. Whatever the query type, the columns are the same, so the downstream DOI, diff and analysis helpers can rely on them. When ``view="COMPLETE"``, an ``authkeywords`` column is added: the author- supplied keywords the Scopus Search API returns under that view, as a single string in Scopus's own ``" | "``-delimited form (``None`` when the document has none, or when the API omits the field for a given key's entitlement: this was observed directly against a live, otherwise fully-entitled key during development, on documents that do carry author keywords in Scopus itself, so an all-``None`` column is more likely an entitlement gap worth raising with your Scopus/Elsevier account contact than genuinely absent data). Any other ``view``, including the default ``None``, reproduces :data:`RECORD_COLUMNS` exactly, so existing callers that never pass ``view`` see no change at all. """add_keywords=view=="COMPLETE"columns=[*RECORD_COLUMNS,"authkeywords"]ifadd_keywordselseRECORD_COLUMNSrows=[]fori,rinenumerate(resultsor[],start=1):date=_get(r,"coverDate")row={"entry_number":i,"scopus_id":_scopus_id(_get(r,"eid")),"doi":_get(r,"doi"),"title":_get(r,"title"),# pybliometrics joins multiple authors with ';' in author_names."authors":_get(r,"author_names")or_get(r,"creator"),"year":_year(date),"date":date,"publication":_get(r,"publicationName"),"citations":_citations(_get(r,"citedby_count")),"query":query,}ifadd_keywords:row["authkeywords"]=_get(r,"authkeywords")orpd.NArows.append(row)returnpd.DataFrame(rows,columns=columns)
Tally the most frequent sources or authors in a record set.
Source code in src/scopusflow/records.py
100101102103104105106107108109110111112
deftop(records:pd.DataFrame,by:str="source",n:int=10)->pd.DataFrame:"""Tally the most frequent sources or authors in a record set."""ifby=="source":values=records["publication"].dropna()elifby=="author":values=(records["authors"].dropna().str.split(";").explode().str.strip())values=values[values!=""]else:raiseValueError("by must be 'source' or 'author'.")counts=values.value_counts().head(n)returncounts.rename_axis("value").reset_index(name="n")
The tally below runs at build time over the bundled example harvest, so it needs
no key.
Bind several record frames into one, renumbering entry_number.
This is the safe way to merge separate harvests: a plain concat leaves
duplicate entry numbers, and nothing then records how many records went in.
Parameters:
Name
Type
Description
Default
*sets
Two or more record frames, or a single list of them.
()
dedupe
bool
When True, records sharing a Scopus identifier, or failing that a
DOI (compared case-insensitively), are kept once.
False
Returns:
Type
Description
DataFrame
The merged records. Attributes describing a single retrieval, among
them plan, total_results and cell_totals, are not carried
over, since a set built from several harvests is none of them. The merge
itself is recorded in attrs["combined"], a dict of n_in (records
supplied), n_out (records kept), n_removed and
deduplicated. That count exists only at the moment
of the merge, and PRISMA-S asks for it (item 16), so
:func:scopusflow.report.scopus_search_report reads it back from there
so the item is answered.
defscopus_combine(*sets,dedupe:bool=False)->pd.DataFrame:"""Bind several record frames into one, renumbering ``entry_number``. This is the safe way to merge separate harvests: a plain ``concat`` leaves duplicate entry numbers, and nothing then records how many records went in. Parameters ---------- *sets: Two or more record frames, or a single list of them. dedupe: When ``True``, records sharing a Scopus identifier, or failing that a DOI (compared case-insensitively), are kept once. Returns ------- pandas.DataFrame The merged records. Attributes describing a single retrieval, among them ``plan``, ``total_results`` and ``cell_totals``, are not carried over, since a set built from several harvests is none of them. The merge itself is recorded in ``attrs["combined"]``, a dict of ``n_in`` (records supplied), ``n_out`` (records kept), ``n_removed`` and ``deduplicated``. That count exists only at the moment of the merge, and PRISMA-S asks for it (item 16), so :func:`scopusflow.report.scopus_search_report` reads it back from there so the item is answered. Examples -------- >>> import scopusflow as sf >>> baseline = sf.example_records() >>> later = sf.example_records() >>> merged = sf.scopus_combine(baseline, later, dedupe=True) >>> merged.attrs["combined"]["n_in"] 276 >>> len(merged) 149 """frames=list(sets)iflen(frames)==1andisinstance(frames[0],(list,tuple)):frames=list(frames[0])ifnotframesornotall(isinstance(f,pd.DataFrame)forfinframes):raiseValueError("All inputs to scopus_combine() must be record frames.")out=pd.concat([_without_attrs(f)forfinframes],ignore_index=True)n_in=len(out)ifdedupe:out=out[~_key(out).duplicated()].reset_index(drop=True)iflen(out):out["entry_number"]=range(1,len(out)+1)else:out=out.reindex(columns=list(out.columns)orlist(RECORD_COLUMNS))out.attrs["combined"]={"n_in":n_in,"n_out":len(out),"n_removed":n_in-len(out),"deduplicated":bool(dedupe),}returnout
Return the bundled example harvest as a records frame.
A fresh copy is returned on each call, so a caller may edit the result
without disturbing anyone else's.
Returns:
Type
Description
DataFrame
138 records with the standard RECORD_COLUMNS schema, covering 2015
to 2024. The harvest is complete, so the rows per
year are the real publications per year for the query. scopus_id is
empty throughout, these records not having come from Scopus; eleven
records carry no DOI and two no source title, exactly as they arrive.
defexample_records()->pd.DataFrame:"""Return the bundled example harvest as a records frame. A fresh copy is returned on each call, so a caller may edit the result without disturbing anyone else's. Returns ------- pandas.DataFrame 138 records with the standard ``RECORD_COLUMNS`` schema, covering 2015 to 2024. The harvest is complete, so the rows per year are the real publications per year for the query. ``scopus_id`` is empty throughout, these records not having come from Scopus; eleven records carry no DOI and two no source title, exactly as they arrive. Examples -------- >>> import scopusflow as sf >>> records = sf.example_records() >>> len(records) 138 >>> sorted(records.columns) == sorted(sf.RECORD_COLUMNS) True """return_load().copy()
Render records as a BibTeX string, one @article entry per row, with
citation keys made unique within the export.
Source code in src/scopusflow/export.py
122123124125126127128129130131
defto_bibtex(records:pd.DataFrame)->str:"""Render records as a BibTeX string, one ``@article`` entry per row, with citation keys made unique within the export."""ifnotisinstance(records,pd.DataFrame):raiseValueError("records must be a pandas DataFrame.")rows=[rowfor_,rowinrecords.iterrows()]keys=_disambiguate([_bibtex_key(r.get("authors"),r.get("year"),r.get("scopus_id"))forrinrows])return"\n\n".join(_bibtex_entry(r,k)forr,kinzip(rows,keys,strict=True))
Render records as an RIS string, one JOUR record per row.
Source code in src/scopusflow/export.py
134135136137138
defto_ris(records:pd.DataFrame)->str:"""Render records as an RIS string, one ``JOUR`` record per row."""ifnotisinstance(records,pd.DataFrame):raiseValueError("records must be a pandas DataFrame.")return"\n\n".join(_ris_entry(row)for_,rowinrecords.iterrows())
Assemble a reproducible record of a Scopus search.
Turns a harvest, or a plan not yet run, into the search-strategy record a
systematic review has to report: what was searched, exactly how, when, how
much came back, and how much the API said there was. The record prints as a
readable report, formats as a methods paragraph fit to paste into a
manuscript, and writes as Markdown. The reporting standard it follows is
PRISMA-S (Rethlefsen et al., 2021), together with the identification counts
of the PRISMA 2020 flow diagram.
Everything in the record comes from the objects handed to it. The date of
the search is the retrieved_at attribute :func:scopusflow.fetch.fetch_plan
attaches, never the current time; the number of records the API reported as
matching is the per-cell accounting in cell_totals, never an inference
from the number of rows; and the duplicates removed are those
:func:scopusflow.combine.scopus_combine recorded removing. Where an
attribute is absent, as it is for a frame read back from CSV, for the
bundled corpus, and for a harvest with a cell resumed from a checkpoint, the
record says the field is unrecorded and fills nothing in. This matters
most for completeness: a harvest whose reported total is unknown is never
described as exhaustive.
The PRISMA-S map is decided the same way. Items the package holds evidence
for (the database and platform, the full strategy, the limits, the date, the
totals, and de-duplication where it was performed) are listed as supplied.
The rest, among them peer review of the strategy, grey literature, other
databases and citation searching, are listed as the author's to supply,
because the package has no way to know them.
Parameters:
Name
Type
Description
Default
x
A records frame, which supplies the counts and the retrieval provenance
and, through attrs["plan"], the plan; or a bare
:class:~scopusflow.plan.SearchPlan for a search not yet run.
The plan describing x. Supply it when a records frame does not carry
one, for instance one read back from CSV. An explicit plan takes
precedence over the one x carries.
None
file
Path at which to write the record as Markdown. A file is written only
when this is supplied, and only to the exact path given, so nothing is
written to the working directory unless asked.
The record. print shows it, report.format(style="paragraph")
gives the methods paragraph.
References
Rethlefsen, M. L., Kirtley, S., Waffenschmidt, S., Ayala, A. P., Moher, D.,
Page, M. J., & Koffel, J. B. (2021). PRISMA-S: an extension to the PRISMA
Statement for Reporting Literature Searches in Systematic Reviews.
Systematic Reviews, 10, 39. https://doi.org/10.1186/s13643-020-01542-z
Examples:
A search described but not yet run. The record says so throughout rather
than implying figures it cannot have.
The same search after a harvest. The bundled corpus stands in for one, since
Scopus records may not be redistributed, so the attributes a live retrieval
records are set here by hand.
defscopus_search_report(x,plan:SearchPlan|None=None,file=None)->SearchReport:"""Assemble a reproducible record of a Scopus search. Turns a harvest, or a plan not yet run, into the search-strategy record a systematic review has to report: what was searched, exactly how, when, how much came back, and how much the API said there was. The record prints as a readable report, formats as a methods paragraph fit to paste into a manuscript, and writes as Markdown. The reporting standard it follows is PRISMA-S (Rethlefsen et al., 2021), together with the identification counts of the PRISMA 2020 flow diagram. Everything in the record comes from the objects handed to it. The date of the search is the ``retrieved_at`` attribute :func:`scopusflow.fetch.fetch_plan` attaches, never the current time; the number of records the API reported as matching is the per-cell accounting in ``cell_totals``, never an inference from the number of rows; and the duplicates removed are those :func:`scopusflow.combine.scopus_combine` recorded removing. Where an attribute is absent, as it is for a frame read back from CSV, for the bundled corpus, and for a harvest with a cell resumed from a checkpoint, the record says the field is unrecorded and fills nothing in. This matters most for completeness: a harvest whose reported total is unknown is never described as exhaustive. The PRISMA-S map is decided the same way. Items the package holds evidence for (the database and platform, the full strategy, the limits, the date, the totals, and de-duplication where it was performed) are listed as supplied. The rest, among them peer review of the strategy, grey literature, other databases and citation searching, are listed as the author's to supply, because the package has no way to know them. Parameters ---------- x: A records frame, which supplies the counts and the retrieval provenance and, through ``attrs["plan"]``, the plan; or a bare :class:`~scopusflow.plan.SearchPlan` for a search not yet run. plan: The plan describing ``x``. Supply it when a records frame does not carry one, for instance one read back from CSV. An explicit plan takes precedence over the one ``x`` carries. file: Path at which to write the record as Markdown. A file is written only when this is supplied, and only to the exact path given, so nothing is written to the working directory unless asked. Returns ------- SearchReport The record. ``print`` shows it, ``report.format(style="paragraph")`` gives the methods paragraph. References ---------- Rethlefsen, M. L., Kirtley, S., Waffenschmidt, S., Ayala, A. P., Moher, D., Page, M. J., & Koffel, J. B. (2021). PRISMA-S: an extension to the PRISMA Statement for Reporting Literature Searches in Systematic Reviews. *Systematic Reviews*, *10*, 39. https://doi.org/10.1186/s13643-020-01542-z Examples -------- A search described but not yet run. The record says so throughout rather than implying figures it cannot have. >>> import scopusflow as sf >>> plan = sf.SearchPlan("graphene supercapacitor", years=range(2015, 2025), ... field="TITLE-ABS-KEY", partition="year") >>> report = sf.scopus_search_report(plan) >>> report.n_records is None True The same search after a harvest. The bundled corpus stands in for one, since Scopus records may not be redistributed, so the attributes a live retrieval records are set here by hand. >>> from datetime import datetime, timezone >>> records = sf.example_records() >>> records.attrs["plan"] = plan >>> records.attrs["retrieved_at"] = datetime(2026, 7, 22, 9, 15, ... tzinfo=timezone.utc) >>> records.attrs["scopusflow_version"] = "0.3.0" >>> report = sf.scopus_search_report(records) >>> report.n_records 138 >>> "22 July 2026" in report.format(style="paragraph") True """ifplanisnotNoneandnotisinstance(plan,SearchPlan):raiseValueError("The plan must be a search plan.")ifisinstance(x,SearchPlan):records=Noneplan=planorxelifisinstance(x,pd.DataFrame):records=xifplanisNone:carried=x.attrs.get("plan")plan=carriedifisinstance(carried,SearchPlan)elseNoneelse:raiseValueError("A search report needs a record set or a search plan.")report=_build(records,plan)iffileisnotNone:ifnotisinstance(file,(str,bytes))andnothasattr(file,"__fspath__"):raiseValueError("The file must be a single non-empty path.")ifisinstance(file,(str,bytes))andnotstr(file).strip():raiseValueError("The file must be a single non-empty path.")# Newline fixed to LF, so the record is byte-identical on every platform# and against the R twin, which writes its text artefacts the same way.withopen(file,"w",encoding="utf-8",newline="\n")asfh:fh.write(_render_markdown(report)+"\n")returnreport
The fields a search record is built from, and its three renderings.
Every attribute is either what the objects recorded or None, which the
renderings spell out as unrecorded. cells is a frame of cell,
limit, n_records and reported_total; prisma a frame of
item, name, source ("record" or "author") and note.
@dataclass(eq=False)classSearchReport:"""The fields a search record is built from, and its three renderings. Every attribute is either what the objects recorded or ``None``, which the renderings spell out as unrecorded. ``cells`` is a frame of ``cell``, ``limit``, ``n_records`` and ``reported_total``; ``prisma`` a frame of ``item``, ``name``, ``source`` (``"record"`` or ``"author"``) and ``note``. """database:str="Scopus"platform:str="Elsevier Scopus Search API"query:str|None=Nonefield:str|None=Noneexpression:list[str]|None=Noneview:str|None=Nonepage_size:int|None=Nonepaging:str|None=Nonepartition:str|None=Nonen_cells:int|None=Noneyears:str|None=Nonecells:pd.DataFrame=dataclass_field(default_factory=pd.DataFrame)searched_at:datetime|None=Noneversion:str|None=Nonen_records:int|None=Nonen_with_doi:int|None=Nonereported_total:int|None=Nonecells_reported:int=0records_combined:int|None=Noneduplicates_removed:int|None=Nonededuplicated:bool|None=Nonesnippet:str|None=Noneprisma:pd.DataFrame=dataclass_field(default_factory=pd.DataFrame)defformat(self,style:str="report")->str:"""Render the record: ``"report"`` for the readable record ``print`` shows, ``"paragraph"`` for the methods paragraph, or ``"markdown"`` for the whole record as Markdown, which is what ``file`` writes."""ifstyle=="report":return_render_report(self)ifstyle=="paragraph":return_render_paragraph(self)ifstyle=="markdown":return_render_markdown(self)raiseValueError("style must be 'report', 'paragraph' or 'markdown'.")def__str__(self)->str:return_render_report(self)__repr__=__str__
Render the record: "report" for the readable record print
shows, "paragraph" for the methods paragraph, or "markdown" for
the whole record as Markdown, which is what file writes.
Source code in src/scopusflow/report.py
108109110111112113114115116117118
defformat(self,style:str="report")->str:"""Render the record: ``"report"`` for the readable record ``print`` shows, ``"paragraph"`` for the methods paragraph, or ``"markdown"`` for the whole record as Markdown, which is what ``file`` writes."""ifstyle=="report":return_render_report(self)ifstyle=="paragraph":return_render_paragraph(self)ifstyle=="markdown":return_render_markdown(self)raiseValueError("style must be 'report', 'paragraph' or 'markdown'.")
Pull cleaned, optionally de-duplicated DOIs from records or a list.
Source code in src/scopusflow/diff.py
37383940414243444546474849
defextract_dois(records,dedupe:bool=True)->list[str]:"""Pull cleaned, optionally de-duplicated DOIs from records or a list."""dois=_clean(_as_dois(records))ifnotdedupe:returndoisseen:set[str]=set()result:list[str]=[]fordindois:key=d.lower()ifkeynotinseen:seen.add(key)result.append(d)returnresult
Compare two retrievals; return a frame of (doi, status) where status is
added, removed or unchanged (compared case-insensitively).
Source code in src/scopusflow/diff.py
52535455565758596061626364
defdiff_dois(old,new)->pd.DataFrame:"""Compare two retrievals; return a frame of (doi, status) where status is ``added``, ``removed`` or ``unchanged`` (compared case-insensitively)."""old_d,new_d=extract_dois(old),extract_dois(new)old_keys={d.lower()fordinold_d}new_keys={d.lower()fordinnew_d}rows=([(d,"added")fordinnew_difd.lower()notinold_keys]+[(d,"removed")fordinold_difd.lower()notinnew_keys]+[(d,"unchanged")fordinnew_difd.lower()inold_keys])df=pd.DataFrame(rows,columns=["doi","status"])returndf.sort_values(["status","doi"]).reset_index(drop=True)
Summarise a literature over time, compare topics within it, and turn the
summaries into figures.
The plotting functions below are pure over the frame they are given, so the
record examples in this section run at build time over the bundled example
harvest, and the topic comparison over a frame of the documented shape. Nothing
here contacts the Scopus API or needs a key. The functions that do retrieve
counts are marked as such and are shown but not run. The guides linked from
each one demonstrate them against a live key.
Count Scopus hits for query in each of years without downloading them.
Each year is a cheap result-size lookup, so this gives a publication trend
far faster than harvesting every record. field wraps the query in a
Scopus field tag (see :data:scopusflow.query.FIELD_TAGS), the way
:func:scopusflow.count.scopus_count and the R twin's scopus_trend()
do; left None, the query is sent as given.
defscopus_trend(query:str,years:Sequence[int],field:str|None=None,view:str="STANDARD",**kwargs,)->pd.DataFrame:"""Count Scopus hits for ``query`` in each of ``years`` without downloading them. Each year is a cheap result-size lookup, so this gives a publication trend far faster than harvesting every record. ``field`` wraps the query in a Scopus field tag (see :data:`scopusflow.query.FIELD_TAGS`), the way :func:`scopusflow.count.scopus_count` and the R twin's ``scopus_trend()`` do; left ``None``, the query is sent as given. """ifnotqueryornotquery.strip():raiseValueError("query must be a non-empty string.")years=list(years)ifnotyears:raiseValueError("years must be a non-empty sequence.")# Validated before it reaches the query: the year used to be# interpolated raw, so a float year sent the API "PUBYEAR IS 2015.0" while# the result was filed under 2015.years=_check_years(years)# Wrapped once, before the loop: the tag applies to the query alone, never# to the year# filter folded in beside it.query=wrap_field(query,field)frompybliometrics.scopusimportScopusSearch# imported lazily; needs a keycounts:dict[int,int]={}foryinyears:search=ScopusSearch(f"{query} AND PUBYEAR IS {y}",view=view,download=False,**kwargs)counts[y]=int(search.get_results_size())return_trend_frame(counts)
This asks the API for a count per year, so it cannot run at build time. See
Analysing a literature for the worked
example, and year_counts below for the offline equivalent over records you
already hold.
Count records per publication year, dropping rows with a missing year.
Returns a :data:TREND_COLUMNS frame sorted ascending by year, with both
columns as plain integers.
Source code in src/scopusflow/trend.py
222324252627282930
defyear_counts(records:pd.DataFrame)->pd.DataFrame:"""Count records per publication year, dropping rows with a missing year. Returns a :data:`TREND_COLUMNS` frame sorted ascending by year, with both columns as plain integers. """years=pd.to_numeric(records["year"],errors="coerce").dropna()counts={int(y):int(n)fory,ninyears.astype(int).value_counts().items()}return_trend_frame(counts)
Compare comparison topics against a reference topic over the years.
Returns a :data:COMPARISON_COLUMNS frame. One count request per term per
year, plus one per year for the reference topic, so keep the term and year
counts modest to stay within quota.
defcompare_topics(reference_query:str,comparison_terms,years:Sequence[int],field:str|None=None,view:str="STANDARD",**kwargs)->pd.DataFrame:"""Compare comparison topics against a reference topic over the years. Returns a :data:`COMPARISON_COLUMNS` frame. One count request per term per year, plus one per year for the reference topic, so keep the term and year counts modest to stay within quota. """ifnotreference_queryornotstr(reference_query).strip():raiseValueError("reference_query must be a non-empty string.")ifisinstance(comparison_terms,str):comparison_terms=[comparison_terms]terms=[str(t).strip()fortincomparison_terms]ifnottermsorany(nottfortinterms):raiseValueError("comparison_terms must be a non-empty list of non-empty terms.")ifyearsisNoneornotlist(years):raiseValueError("years must be a non-empty sequence.")ys=sorted(set(_check_years(years)))frompybliometrics.scopusimportScopusSearch# imported lazily; needs a keydefsize(query:str,year:int)->int:full=f"{query} AND PUBYEAR IS {year}"returnint(ScopusSearch(full,view=view,download=False,**kwargs).get_results_size())ref_query=wrap_field(str(reference_query).strip(),field)# One count step per term, plus the reference; logged as "Cell k/N:" so the# app's progress parser can drive a bar (mirrors the R verbose output).total=len(terms)+1logger.info("Cell 1/%d: counting reference across %d year(s)",total,len(ys))ref_counts={y:size(ref_query,y)foryinys}comparison=[]fori,terminenumerate(terms):logger.info("Cell %d/%d: counting '%s'",i+2,total,term)cmp_query=f"{ref_query} AND {wrap_field(term,field)}"comparison.append((term,cmp_query,{y:size(cmp_query,y)foryinys}))return_assemble(str(reference_query).strip(),ref_query,ref_counts,comparison,ys)
This makes one count request per term per year, so it cannot run at build time.
See Comparing topics for the worked example.
Count each concept and each requested intersection of concepts.
Parameters:
Name
Type
Description
Default
concepts
Mapping[str, str]
A mapping whose keys are display labels and whose values are search terms
(wrapped in field when one is given) or complete field-tagged query
expressions such as "TITLE(virtual reality)" (used as-is). The labels
must be unique.
required
intersections
Sequence[Sequence[str]] | None
An optional sequence of sequences, each naming two or more distinct
concept labels whose intersection should be counted, for example
[["A", "B"], ["A", "B", "C"]]. A single flat sequence of labels is
taken as one intersection.
None
abbrev
Mapping[str, str] | None
An optional mapping of short labels, keyed by concept label and used only
when composing intersection labels, so those rows stay readable while the
concept rows keep their full names.
None
sep
str
The separator joining member labels in an intersection label; defaults to
a multiplication sign between spaces.
' × '
years
Sequence[int] | None
An optional inclusive year range applied to every count.
None
field
str | None
An optional Scopus field tag wrapped around each concept value that is not
already a complete field-tagged expression (see
:data:scopusflow.query.FIELD_TAGS).
None
view
str
The Scopus search view, "STANDARD" or "COMPLETE".
'STANDARD'
verbose
bool
When True, progress is reported on the scopusflow logger.
False
Returns:
Type
Description
DataFrame
One row per concept and per intersection, with columns label,
query, n (the count, as a nullable integer), type
("concept" or "intersection"), size (the number of member
concepts) and members (the member labels, joined by "; "). The
years restriction, when given, is stored in df.attrs["years"].
Notes
This performs one count request per concept and per intersection, so it needs
a valid API key and internet access, exactly as
:func:scopusflow.count.scopus_count does.
defscopus_intersections(concepts:Mapping[str,str],intersections:Sequence[Sequence[str]]|None=None,abbrev:Mapping[str,str]|None=None,sep:str=" × ",years:Sequence[int]|None=None,field:str|None=None,view:str="STANDARD",verbose:bool=False,**kwargs,)->pd.DataFrame:"""Count each concept and each requested intersection of concepts. Parameters ---------- concepts: A mapping whose keys are display labels and whose values are search terms (wrapped in ``field`` when one is given) or complete field-tagged query expressions such as ``"TITLE(virtual reality)"`` (used as-is). The labels must be unique. intersections: An optional sequence of sequences, each naming two or more distinct concept labels whose intersection should be counted, for example ``[["A", "B"], ["A", "B", "C"]]``. A single flat sequence of labels is taken as one intersection. abbrev: An optional mapping of short labels, keyed by concept label and used only when composing intersection labels, so those rows stay readable while the concept rows keep their full names. sep: The separator joining member labels in an intersection label; defaults to a multiplication sign between spaces. years: An optional inclusive year range applied to every count. field: An optional Scopus field tag wrapped around each concept value that is not already a complete field-tagged expression (see :data:`scopusflow.query.FIELD_TAGS`). view: The Scopus search view, ``"STANDARD"`` or ``"COMPLETE"``. verbose: When ``True``, progress is reported on the ``scopusflow`` logger. Returns ------- pandas.DataFrame One row per concept and per intersection, with columns ``label``, ``query``, ``n`` (the count, as a nullable integer), ``type`` (``"concept"`` or ``"intersection"``), ``size`` (the number of member concepts) and ``members`` (the member labels, joined by ``"; "``). The ``years`` restriction, when given, is stored in ``df.attrs["years"]``. Notes ----- This performs one count request per concept and per intersection, so it needs a valid API key and internet access, exactly as :func:`scopusflow.count.scopus_count` does. """out=_intersection_rows(concepts,intersections,abbrev,sep,field)ys=sorted(set(_check_years(years)))ifyearselseNoneifverbose:n_inter=int((out["type"]=="intersection").sum())logger.info("Counting %d queries (%d intersection%s).",len(out),n_inter,""ifn_inter==1else"s",)counts:list[int]=[]forlabel,queryinzip(out["label"],out["query"],strict=True):ifverbose:logger.info("Counting %s",label)counts.append(scopus_count(query,years=ys,view=view,**kwargs))out["n"]=pd.array(counts,dtype="Int64")out=out[["label","query","n","type","size","members"]]out.attrs["years"]=ysreturnout
This makes one count request per concept and per intersection, so it cannot run
at build time. See Analysing a literature
for the worked example.
Plot publication counts over time as a filled area, line and points.
trend has columns ["year", "n"]; returns the matplotlib Axes.
Source code in src/scopusflow/plots.py
3132333435363738394041424344454647484950
defplot_trend(trend:pd.DataFrame,ax=None):"""Plot publication counts over time as a filled area, line and points. ``trend`` has columns ``["year", "n"]``; returns the matplotlib ``Axes``. """importmatplotlib.pyplotaspltifaxisNone:_,ax=plt.subplots()years=trend["year"]counts=trend["n"]ax.fill_between(years,counts,alpha=0.16,color=_TREND_COLOUR)ax.plot(years,counts,color=_TREND_COLOUR,linewidth=2)ax.scatter(years,counts,color=_TREND_COLOUR,s=18,zorder=3)ax.set_ylim(bottom=0)ax.set_xlabel("Year")ax.set_ylabel("Records")_clean_axes(ax)returnax
Plot a horizontal bar chart of the most frequent values, largest on top.
top has columns ["value", "n"] (from :func:scopusflow.records.top);
each bar carries its count at its end, matching the R plot_scopus_top.
Returns the matplotlib Axes.
defplot_top(top:pd.DataFrame,ax=None):"""Plot a horizontal bar chart of the most frequent values, largest on top. ``top`` has columns ``["value", "n"]`` (from :func:`scopusflow.records.top`); each bar carries its count at its end, matching the R ``plot_scopus_top``. Returns the matplotlib ``Axes``. """importmatplotlib.pyplotasplt# An all-missing publication column tallies to no rows at all, which would# otherwise surface as an opaque max() error on the label widths below.iflen(top)==0:raiseValueError("The tally has no rows to plot.")ifaxisNone:_,ax=plt.subplots()# Reverse so the largest count sits at the top of the chart.ordered=top.iloc[::-1]bars=ax.barh(ordered["value"].astype(str),ordered["n"],color=_TOP_COLOUR)labels=[f"{int(n):,}"forninordered["n"]]ax.bar_label(bars,labels=labels,padding=3,fontsize=8,color="#4d4d4d")# Headroom derived from the widest label, so the count on the longest bar# stays inside the axes, where a wide one would otherwise clip at the# right edge. Mirrors the# R plot's label-width-derived axis expansion.ax.margins(x=0.04+0.024*max(len(lab)forlabinlabels))ax.set_xlim(left=0)ax.set_xlabel("Records")ax.set_ylabel("")_clean_axes(ax)returnax
Plot each comparison topic's share of the reference literature over time.
comparison is the frame from :func:scopusflow.compare.compare_topics.
With interval a shaded Wilson band shows how stable each yearly share is
(illustrative, never a confidence interval, since Scopus counts are exact).
highlight names one topic to draw in an accent colour, the rest in grey.
With counts_in_legend (the default) each label carries the topic's total
record count, for example machine learning (n = 1,204).
A legend is only drawn when there are too many topics to label the lines
directly. legend_inside governs where it then sits: with the default
False matplotlib chooses the least-obtrusive spot automatically
(loc="best"); with True the legend is placed inside the axes, in
whichever corner has the most free space, saving the width an outside legend
would otherwise take. Returns the matplotlib Axes.
defplot_comparison(comparison:pd.DataFrame,highlight=None,interval:bool=True,counts_in_legend:bool=True,ax=None,legend_inside:bool=False):"""Plot each comparison topic's share of the reference literature over time. ``comparison`` is the frame from :func:`scopusflow.compare.compare_topics`. With ``interval`` a shaded Wilson band shows how stable each yearly share is (illustrative, never a confidence interval, since Scopus counts are exact). ``highlight`` names one topic to draw in an accent colour, the rest in grey. With ``counts_in_legend`` (the default) each label carries the topic's total record count, for example ``machine learning (n = 1,204)``. A legend is only drawn when there are too many topics to label the lines directly. ``legend_inside`` governs where it then sits: with the default ``False`` matplotlib chooses the least-obtrusive spot automatically (``loc="best"``); with ``True`` the legend is placed inside the axes, in whichever corner has the most free space, saving the width an outside legend would otherwise take. Returns the matplotlib ``Axes``. """importmatplotlib.pyplotaspltimportmatplotlib.tickerasmtickerrequired={"query_type","abridged_query","year","comparison_percentage","average_comparison_percentage"}ifnotrequired.issubset(comparison.columns):raiseValueError("comparison must be a topic-comparison frame.")comp_all=comparison[comparison["query_type"]=="comparison"]# A year whose reference has no records carries no defined share; it is# dropped and noted in the caption, mirroring the R plot.n_missing=int(comp_all["comparison_percentage"].isna().sum())df=comp_all[comp_all["comparison_percentage"].notna()].copy()ifdf.empty:raiseValueError("comparison has no comparison topics with a finite share to plot.")# The reference topic names the subtitle (it is the 100% denominator).ref_rows=comparison[comparison["query_type"]=="reference"]ref_names=ref_rows["abridged_query"].dropna().unique()iflen(ref_rows)else[]ref_label=str(ref_names[0])iflen(ref_names)==1elseNoneorder=(df.groupby("abridged_query")["average_comparison_percentage"].first().reset_index().sort_values(["average_comparison_percentage","abridged_query"],ascending=[False,True]))topics=list(order["abridged_query"])ifhighlightisnotNoneandhighlightnotintopics:raiseValueError(f"highlight must be one of: {', '.join(topics)}.")# Optionally append each topic's total record count to its label.has_counts=counts_in_legendand"n"indf.columnstotals=df.groupby("abridged_query")["n"].sum()ifhas_countselseNonedef_label(topic):returnf"{topic} (n = {int(totals[topic]):,})"ifhas_countselsetopicifaxisNone:_,ax=plt.subplots()cmap=plt.get_cmap("viridis")spread=max(len(topics)-1,1)has_band=intervaland{"n","reference_n"}.issubset(df.columns)label_points=[]fori,topicinenumerate(topics):sub=df[df["abridged_query"]==topic].sort_values("year")is_hi=highlight==topicifhighlightisnotNone:colour="#BB5566"ifis_hielse"#BFBFBF"width=1.6ifis_hielse0.8else:colour=cmap(0.05+0.8*i/spread)width=1.4ifhas_bandand(highlightisNoneoris_hi):lo,up=_wilson(sub["n"].to_numpy(),sub["reference_n"].to_numpy())ax.fill_between(sub["year"],lo,up,color=colour,alpha=0.16,linewidth=0)ax.plot(sub["year"],sub["comparison_percentage"],color=colour,linewidth=width,label=_label(topic))ax.scatter(sub["year"],sub["comparison_percentage"],color=colour,s=14,zorder=3)last=sub.iloc[-1]label_points.append((float(last["year"]),float(last["comparison_percentage"]),_label(topic),colour,is_hi))# Cap the y-axis at the next 5% above the data (and bands), as the R plot# does, to remove dead headroom.ifhas_band:_,band_upper=_wilson(df["n"].to_numpy(),df["reference_n"].to_numpy())top=max(float(df["comparison_percentage"].max()),float(band_upper.max()))else:top=float(df["comparison_percentage"].max())ymax=min(100,math.ceil(top/5)*5)ax.set_ylim(0,ymax)# Label the lines directly when they fit legibly; otherwise fall back to a# legend. The gap is the minimum vertical separation between labels.gap=ymax*0.055ifhighlightisnotNone:to_label=[pforpinlabel_pointsifp[4]]eliflen(topics)<=8and(len(topics)-1)*gap<=ymax:to_label=label_pointselse:to_label=[]iflegend_inside:legend_loc=_free_corner(df["year"],df["comparison_percentage"],(float(df["year"].min()),float(df["year"].max())),(0.0,ymax))else:legend_loc="best"ax.legend(fontsize=8,loc=legend_loc,frameon=False,ncol=2iflen(topics)>8else1)# Place each label just past its line's endpoint. The labels are spread# apart at the end (see _decollide_once), once the final layout is known,# so none overlaps another.anns,label_xs,label_y_true=[],[],[]ifto_label:years=df["year"]dx=(float(years.max())-float(years.min()))*0.015+0.1forx,y_true,topic,colour,_is_hiinto_label:# No leader line: where labels converge they are nudged apart, and a# leader to a nudged label then cuts across the neighbouring labels'# text. The colour match and shared top-to-bottom order keep each# label tied to its line without one.anns.append(ax.annotate(topic,xy=(x,y_true),xytext=(x+dx,y_true),textcoords="data",va="center",ha="left",fontsize=8,color=colour,annotation_clip=False,))label_xs.append(x+dx)label_y_true.append(y_true)ax.set_xlabel("")ax.set_ylabel("Share of reference records")ax.yaxis.set_major_formatter(mticker.PercentFormatter(xmax=100,decimals=0))ax.set_title("Topic share within a reference literature, over time",loc="left",fontsize=12,pad=22)ifref_label:ax.annotate(f"Each line: % of '{ref_label}' records that also match the topic",xy=(0,1),xycoords="axes fraction",xytext=(0,6),textcoords="offset points",ha="left",va="bottom",fontsize=9,color="#555555",)# A caption that names the source and guards against reading the illustrative# Wilson band as an inferential confidence interval (it is not).caption=(f"Source: 'Scopus' Search API. Years {int(df['year'].min())} "f"to {int(df['year'].max())}.")ifhas_band:caption+=("\nShaded band: illustrative Wilson stability range ""(not a confidence interval), wider where the reference set is small.")ifn_missing>0:plural=""ifn_missing==1else"s"caption+=(f"\n{n_missing} year-topic value{plural} omitted for want ""of reference records.")ax.annotate(caption,xy=(0,0),xycoords="axes fraction",xytext=(0,-24),textcoords="offset points",ha="left",va="top",fontsize=7.5,color="#737373")_clean_axes(ax)ifanns:# Re-spread the labels on every draw, measuring the rendered text height in# the layout that actually applies, so they never overlap however the lines# converge or the figure is sized. Running on the draw means it stays# correct after the caller tightens the layout. A guard stops the redraw it# requests from recursing once the positions have settled._busy={"on":False}def_on_draw(_event=None):if_busy["on"]:return_busy["on"]=Truetry:if_decollide_once(ax,anns,label_xs,label_y_true,ymax,gap):ax.figure.canvas.draw_idle()finally:_busy["on"]=Falseax.figure.canvas.mpl_connect("draw_event",_on_draw)# An initial draw so a one-shot render (savefig without a prior draw) still# gets de-collided labels.try:ax.figure.canvas.draw()exceptException:passreturnax
years=list(range(2013,2022))ref_n=[400,550,700,850,1000,1150,1300,1450,1600]shares={"computer vision":34.0,"natural language processing":24.0,"medical imaging":11.0,"drug discovery":6.0}rows=[{"query":"deep learning","query_type":"reference","abridged_query":"deep learning","year":year,"n":n,"reference_n":n,"comparison_percentage":100.0,"average_comparison_percentage":100.0}foryear,ninzip(years,ref_n)]fortopic,endinshares.items():fori,(year,n)inenumerate(zip(years,ref_n)):pct=end*(0.45+0.55*i/(len(years)-1))rows.append({"query":topic,"query_type":"comparison","abridged_query":topic,"year":year,"n":int(pct*n/100),"reference_n":n,"comparison_percentage":pct,"average_comparison_percentage":end})comparison=pd.DataFrame(rows,columns=sf.COMPARISON_COLUMNS)sf.plot_comparison(comparison)show()
Plot concept and intersection counts as a horizontal lollipop chart.
x is a frame from :func:scopusflow.intersections.scopus_intersections
(columns label, n and type). Counts run along a logarithmic x
axis, so fields spanning orders of magnitude sit together with their smaller
intersections. Rows named in highlight are drawn in the focal colour, and
highlight_label names them in the legend (derived from their type when
unset). Returns the matplotlib Axes. Mirrors the R
plot_scopus_intersections.
defplot_scopus_intersections(x,highlight=None,highlight_label=None,ax=None):"""Plot concept and intersection counts as a horizontal lollipop chart. ``x`` is a frame from :func:`scopusflow.intersections.scopus_intersections` (columns ``label``, ``n`` and ``type``). Counts run along a logarithmic x axis, so fields spanning orders of magnitude sit together with their smaller intersections. Rows named in ``highlight`` are drawn in the focal colour, and ``highlight_label`` names them in the legend (derived from their type when unset). Returns the matplotlib ``Axes``. Mirrors the R ``plot_scopus_intersections``. """importwarningsimportmatplotlib.pyplotaspltfrommatplotlib.linesimportLine2Dfrommatplotlib.tickerimportFuncFormatter,NullFormatterrequired={"label","n","type"}ifnotrequired.issubset(x.columns):raiseValueError("x must have columns 'label', 'n' and 'type' (see scopus_intersections()).")df=x.copy()iflen(df)==0:raiseValueError("The intersections frame has no rows to plot.")ifdf["label"].duplicated().any():raiseValueError("The 'label' column must be unique to place each row on its own line.")ifhighlightisNone:highlight=[]elifisinstance(highlight,str):highlight=[highlight]else:highlight=list(highlight)known=set(df["label"])ifhighlightandany(hnotinknownforhinhighlight):raiseValueError("highlight must name rows to accent, among: "+", ".join(map(str,df["label"]))+".")counts=pd.to_numeric(df["n"],errors="coerce")keep=counts.notna()&(counts>0)n_dropped=int((~keep).sum())ifn_dropped:warnings.warn(f"{n_dropped} row(s) without a positive count cannot sit on a log axis ""and were dropped.",stacklevel=2,)df=df.loc[keep].copy()df["n"]=counts[keep].astype(float)iflen(df)==0:raiseValueError("No row has a positive count to place on the log axis.")ifhighlight_labelisNone:hi_types=set(df.loc[df["label"].isin(highlight),"type"])highlight_label={frozenset({"intersection"}):"Focal intersection",frozenset({"concept"}):"Focal concept",}.get(frozenset(hi_types),"Focal set")df["grp"]=["highlight"iflabinhighlightelsetypforlab,typinzip(df["label"],df["type"],strict=True)]df=df.sort_values("n",kind="stable").reset_index(drop=True)colours={"concept":_CONCEPT_COLOUR,"intersection":_INTERSECTION_COLOUR,"highlight":_HIGHLIGHT_COLOUR,}legend_names={"concept":"Concept","intersection":"Intersection","highlight":highlight_label,}lo=max(1.0,df["n"].min())*0.55# the smallest point clears the axishi=df["n"].max()*4# headroom for the widest count label# A constant ratio (not increment) beyond each point renders as a constant# pixel gap on a log axis, mirroring the R plot's label placement.gap_mult=10**(0.024*math.log10(hi/lo))ifaxisNone:_,ax=plt.subplots()fory,(_,row)inenumerate(df.iterrows()):colour=colours[row["grp"]]ax.hlines(y,lo,row["n"],color=colour,linewidth=1.6,zorder=1)ax.scatter(row["n"],y,color=colour,s=42,zorder=2)ax.text(row["n"]*gap_mult,y,f"{int(row['n']):,}",va="center",ha="left",fontsize=8,color="#4d4d4d")ax.set_yticks(range(len(df)))ax.set_yticklabels(df["label"])ax.set_xscale("log")ax.set_xlim(lo,hi)ax.xaxis.set_major_formatter(FuncFormatter(lambdav,_pos:f"{int(v):,}"ifv>=1elsef"{v:g}"))ax.xaxis.set_minor_formatter(NullFormatter())ax.set_xlabel("Records (log scale)")ax.set_title("Records matching each concept and intersection")_clean_axes(ax)present=[gforgin("concept","intersection","highlight")if(df["grp"]==g).any()]handles=[Line2D([0],[0],marker="o",linestyle="",markersize=7,color=colours[g],label=legend_names[g])forginpresent]ifhandles:ax.legend(handles=handles,loc="upper left",frameon=False,fontsize=8)returnax