CoolFace
Apppublic

wfr2/species-synonym-api

sourceHugging Faceupdated 3mo agoView on Hugging Face
0likes
base.py824 linesDownload Raw Back to apis_pipe
1"""2Abstract base class for biodiversity database API clients.3 4All external database connectors in ``apis_pipe`` subclass ``SpeciesAPI`` and5implement its three-phase pipeline contract.  The three phases are enforced by6naming convention:7 8- ``_fetch_*`` — network calls only; return raw responses without parsing.9- ``_extract_*`` — pure string extraction from already-fetched data; no I/O.10- ``_compile_*`` — row assembly; call helpers and read dict keys, no cleaning11  or network calls.12 13The single public entry point is ``get_synonyms(name)``, which orchestrates14all three phases and returns a schema-validated ``pd.DataFrame``.15 16Set the ``APIS_PIPE_VERBOSE`` env var (or ``SpeciesAPI.VERBOSE = True`` at17runtime) to print a trace of data flowing through each pipeline stage and a18warning whenever a fetch call returns blank/empty data — most queries miss on19most portals (each source only covers part of the tree of life), so this is20off by default and intended for development/debugging.21"""22 23import inspect24import os25import re26import xml.etree.ElementTree as ET27from abc import ABC, abstractmethod28 29import pandas as pd30import requests31from dotenv import load_dotenv32 33from scripts.utils.normalize_query_string import normalize_query_string34from scripts.utils.schema import empty_synonym_table, make_synonym_row35 36load_dotenv()37 38 39class _Unset:40    """Sentinel for _format_row optional params that were not provided by the caller."""41 42    __slots__ = ()43 44    def __repr__(self) -> str:45        return "<NOT_PROVIDED>"46 47 48_UNSET = _Unset()49 50 51class SpeciesAPI(ABC):52    """53    Abstract base class establishing a unified contract for biodiversity database clients.54 55    Concrete subclasses must define a ``BASE_URL`` class attribute and implement56    the five abstract methods: ``_fetch_query_data``, ``_fetch_synonym_data``,57    ``_fetch_accepted_data``, ``_compile_synonyms``, and58    ``_compile_accepted``.  Optional helpers may be implemented and/or59    overridden to customise behavior for a specific source.60 61    Attributes62    ----------63    HEADERS : dict64        HTTP headers sent with every request.  Overrides the default65        ``requests`` User-Agent so that portals that reject bot agents respond66        normally.67    """68 69    HEADERS: dict = {"User-Agent": "Mozilla/5.0"}70    BASE_URL: str71    # Default request timeout (seconds) for all fetch helpers. Subclasses that72    # need longer can override this single attribute, e.g. ``_TIMEOUT = 60``.73    _TIMEOUT: int = 3074    # Verbose/debug logging toggle shared by all subclasses. Defaults from the75    # APIS_PIPE_VERBOSE env var; can also be flipped at runtime, e.g.76    # ``SpeciesAPI.VERBOSE = True`` (all clients) or ``GBIFAPI.VERBOSE = True``77    # (one client). When enabled, fetch wrappers print a warning whenever a78    # call returns blank/empty data, and get_synonyms() traces the raw data79    # flowing between pipeline stages.80    VERBOSE: bool = os.getenv("APIS_PIPE_VERBOSE", "").strip().lower() in (81        "1",82        "true",83        "yes",84        "on",85    )86    _INFRASPECIFIC_RE: re.Pattern = re.compile(87        r"\b(var\.|subsp\.|ssp\.|f\.|fo\.|subf\.|cv\.|sect\.|subsect\.|ser\.|subgen\.|subg\.)",88        # TODO: put this in a config, rather than having it inside this file so a user could add if needed89        re.IGNORECASE,90    )91 92    def __init_subclass__(cls, **kwargs):93        super().__init_subclass__(**kwargs)94        if not inspect.isabstract(cls) and not hasattr(cls, "BASE_URL"):95            raise TypeError(f"{cls.__name__} must define a BASE_URL class attribute.")96 97    # ------------------------------------------------------------------98    # Query methods (to be used by children to implement the required methods, can be optionally overridden but should work for most children as-is)99    # ------------------------------------------------------------------100 101    def _fetch(102        self, url: str, params: dict = {}, timeout: int | None = None103    ) -> requests.Response:104        """105        Make a GET request to the specified URL with error handling.106 107        Parameters108        ----------109        url : str110            The full URL to send the GET request to.111        params : dict, optional112            Query parameters to include in the request. Default is an empty dict.113        timeout : int, optional114            Request timeout in seconds. Defaults to the client's ``_TIMEOUT``115            (30s in the base class) when not provided.116 117        Returns118        -------119        requests.Response120            The response object if the request is successful.121 122        Raises123        ------124        requests.RequestException125            If the request times out, the source is unreachable, or the126            response has a non-2xx HTTP status.127        """128        if timeout is None:129            timeout = self._TIMEOUT130        try:131            response = requests.get(132                url, params=params, headers=self.HEADERS, timeout=timeout133            )134            response.raise_for_status()135            return response136        except requests.RequestException as e:137            print(f"{type(self).__name__} fetch error [{url}]: {e}")138            raise139 140    def _fetch_JSON(141        self, url: str, params: dict = {}, timeout: int | None = None142    ) -> dict:143        """144        Make a GET request to a REST JSON endpoint and return the parsed response.145 146        Used by children that query standard REST APIs returning JSON.147 148        Parameters149        ----------150        url : str151            Full URL of the endpoint.152        params : dict, optional153            URL query parameters.154        timeout : int, optional155            Request timeout in seconds. Defaults to the client's ``_TIMEOUT``156            (30s in the base class) when not provided.157 158        Returns159        -------160        dict161            Parsed JSON response, or ``{}`` on any error (network, HTTP, or a162            response body that is not valid JSON).163        """164 165        response = self._fetch(url, params=params, timeout=timeout)166        if response is None:167            return {}168        try:169            return response.json()170        except ValueError:171            print(f"{type(self).__name__} error parsing JSON.")172            return {}173 174    def _fetch_XML(175        self, url: str, params: dict = {}, timeout: int | None = None176    ) -> ET.Element:177        """178        Make a GET request and return the parsed XML root element.179 180        Used by children that consume XML responses. On a parse error of an181        otherwise successful response, prints a message and returns an empty182        ``ET.Element``.183 184        Parameters185        ----------186        url : str187            Full URL of the endpoint.188        params : dict, optional189            URL query parameters.190        timeout : int, optional191            Request timeout in seconds. Defaults to the client's ``_TIMEOUT``192            (30s in the base class) when not provided.193 194        Returns195        -------196        xml.etree.ElementTree.Element197            Parsed root element of the XML response, or an empty element198            if the response body could not be parsed as XML.199 200        Raises201        ------202        requests.RequestException203            If the underlying request fails. See ``_fetch``.204        """205        response = self._fetch(url, params=params, timeout=timeout)206        root = None207        if response is not None:208            try:209                root = ET.fromstring(response.text)210            except ET.ParseError:211                print(f"{type(self).__name__} error parsing XML.")212        if root is None:213            root = ET.Element(214                "empty"215            )  # tag name chosen to avoid confusion with valid root tags in responses, will be treated as empty by _is_empty()216        self._warn_blank(url, root, params)217        return root218 219    def _fetch_HTML(220        self, url: str, params: dict = {}, timeout: int | None = None221    ) -> str:222        """223        Make a GET request and return the raw HTML response text.224 225        Used by children that scrape HTML pages.226 227        Parameters228        ----------229        url : str230            Full URL of the endpoint.231        params : dict, optional232            URL query parameters.233        timeout : int, optional234            Request timeout in seconds. Defaults to the client's ``_TIMEOUT``235            (30s in the base class) when not provided.236 237        Returns238        -------239        str240            Raw HTML text of the response.241 242        Raises243        ------244        requests.RequestException245            If the underlying request fails. See ``_fetch``.246        """247        response = self._fetch(url, params=params, timeout=timeout)248        text = response.text if response is not None else ""249        self._warn_blank(url, text, params)250        return text251 252    # ------------------------------------------------------------------253    # Verbose/debug logging helpers (gated by VERBOSE; can be optionally overridden)254    # ------------------------------------------------------------------255 256    def _warn_blank(self, url: str, data, params: dict | None = None) -> None:257        """258        Print a warning when a fetch call returned blank/empty data.259 260        Only prints when ``VERBOSE`` is enabled. Called by the ``_fetch_JSON``,261        ``_fetch_XML``, and ``_fetch_HTML`` wrappers on whatever they are262        about to return, whether the request failed outright (``_fetch``263        also reports the network/HTTP error separately and unconditionally)264        or merely succeeded with no usable data (e.g. no match found for the265        query).266 267        Parameters268        ----------269        url : str270            The URL that was requested.271        data : list, str, dict, or xml.etree.ElementTree.Element272            The parsed response to check for emptiness.273        params : dict, optional274            Query parameters sent with the request, included in the warning275            for context.276        """277        if self.VERBOSE and self._is_empty(data):278            suffix = f" (params={params})" if params else ""279            print(280                f"[{type(self).__name__}] WARNING: blank/empty response from {url}{suffix}"281            )282 283    def _warn_if_blank(self, step: str, data) -> None:284        """285        Print a warning when a pipeline step's result is blank/empty.286 287        Only prints when ``VERBOSE`` is enabled. Unlike ``_warn_blank``288        (network-level, keyed by URL), this is used by ``get_synonyms`` for289        steps whose underlying fetch response was not itself blank, but290        which produced no usable data after extraction (e.g. an empty291        synonym list).292 293        Parameters294        ----------295        step : str296            Label identifying which pipeline stage produced *data*.297        data : list, str, dict, or xml.etree.ElementTree.Element298            The data to check for emptiness.299        """300        if self.VERBOSE and self._is_empty(data):301            print(f"[{type(self).__name__}] WARNING: {step} returned blank/empty data.")302 303    # ------------------------------------------------------------------304    # Boolean checker methods (to be used by children in their implementations of the required methods,can be optionally overridden but should work for most children as-is)305    # ------------------------------------------------------------------306 307    def _is_empty(self, input):308        """309        Return True if the input is blank, empty, or None.310 311        Parameters312        ----------313        input : list, str, dict, xml.etree.ElementTree.Element, or None314            The value to test for emptiness.315 316        Returns317        -------318        bool319            True if *input* is ``None``, ``""``, ``[]``, ``{}``, or an320            ``ET.Element`` with no children; False otherwise.321        """322        if input == {}:323            return True324        elif input == []:325            return True326        elif input == "":327            return True328        elif input is None:329            return True330        elif isinstance(input, ET.Element) and len(input) == 0:331            return True332        else:333            return False334 335    def _is_infraspecific(self, string: str) -> bool:336        """337        Return True if *string* is an infraspecific scientific name.338 339        Two checks are applied:340 341        1. Rank-marker check — detects explicit infraspecific abbreviations342           such as ``var.``, ``subsp.``, ``f.``, etc. via ``_INFRASPECIFIC_RE``.343        2. Bare-trinomial check — any name with three or more whitespace-344           delimited tokens (e.g. ``"Gadus morhua morhua"``) is treated as345           infraspecific even without a rank marker.346 347        Parameters348        ----------349        string : str350            A scientific name string to inspect.351 352        Returns353        -------354        bool355            True when either check matches, False otherwise.356        """357        return bool(self._INFRASPECIFIC_RE.search(string)) or len(string.split()) >= 3358 359    # ------------------------------------------------------------------360    # Extraction helper methods (to be overriden by children in their implementations of the required methods)361    # ------------------------------------------------------------------362 363    def _extract_publication_year(self, string: str) -> str:364        """365        Extract a four-digit publication year from a scientific name string.366 367        Parameters368        ----------369        string : str370            A scientific name or authorship string that may contain a year.371 372        Returns373        -------374        str375            Four-digit year string, or ``""`` if not found.376 377        Raises378        ------379        NotImplementedError380            When the child class has not provided an implementation.381        """382        raise NotImplementedError(383            f"{type(self).__name__} does not implement _extract_publication_year()."384        )385 386    def _extract_author(self, string: str) -> str:387        """388        Extract the authorship string from a string.389 390        Parameters391        ----------392        string : str393            A string that may contain an authorship component, such as a scientific name or a full citation.394 395        Returns396        -------397        str398            The authorship string (e.g. ``"(L.) Lam."``), or ``""`` if not found.399 400        Raises401        ------402        NotImplementedError403            When the child class has not provided an implementation.404        """405        raise NotImplementedError(406            f"{type(self).__name__} does not implement _extract_author()."407        )408 409    def _extract_publication_name(self, string: str) -> str:410        """411        Extract a publication name from a string.412 413        Parameters414        ----------415        string : str416            A string that may contain the title of the original publication of a species name, such as a citation of a journal or book.417 418        Returns419        -------420        str421            The publication name string, or ``""`` if not found.422 423        Raises424        ------425        NotImplementedError426            When the child class has not provided an implementation.427        """428        raise NotImplementedError(429            f"{type(self).__name__} does not implement _extract_publication_name()."430        )431 432    def _extract_status(self, string: str) -> str:433        """434        Map a raw status string to the schema's ``"Accepted"`` or ``"Synonym"``435        values by checking for those substrings.436 437        Parameters438        ----------439        string : str440            A raw status string from an API response, e.g. ``"accepted"``,441            ``"accepted name"``, ``"ambiguous synonym"``.442 443        Returns444        -------445        str446            ``"Accepted"``, ``"Synonym"``, or ``""`` if neither substring is found.447        """448        lower = string.lower()449        if "accepted" in lower:450            return "Accepted"451        if "synonym" in lower:452            return "Synonym"453        return ""454 455    def _extract_taxonomy(self, data: dict | list | str | ET.Element) -> dict[str, str]:456        """457        Extract taxonomy fields from a raw API response.458 459        Implementations should return a dict with any subset of the following460        keys, using ``class_`` (not ``class``) for the class rank to avoid the461        Python keyword conflict::462 463            {464                "kingdom":  str,465                "phylum":   str,466                "class_":   str,467                "order":    str,468                "family":   str,469                "subfamily": str,470            }471 472        Ranks not implemented should be omitted from the dict rather than included as473        empty strings; ``_format_row`` treats absent keys as ``UNAVAILABLE``.474        The dict is typically unpacked with ``**taxonomy`` directly into a475        ``_format_row`` call.476 477        Parameters478        ----------479        data : any480            Raw API response data in the source's native format (varies by481            subclass — e.g. a ``dict``, ``list``, or482            ``xml.etree.ElementTree.Element``).483 484        Returns485        -------486        dict[str, str]487            Taxonomy field dict with string values for each rank present.488 489        Raises490        ------491        NotImplementedError492            When the child class has not provided an implementation.493        """494        raise NotImplementedError(495            f"{type(self).__name__} does not implement _extract_taxonomy()."496        )497 498    def _extract_genus_species(self, name: str) -> tuple[str, str]:499        """500        Parse a scientific name string into its genus and species components.501 502        Parameters503        ----------504        name : str505            A scientific name string whose first two whitespace-delimited506            tokens are the genus and species epithet (e.g.507            ``"Amanita muscaria"`` or ``"Amanita muscaria var. flavivolvata"``).508 509        Returns510        -------511        tuple[str, str]512            ``(genus, species)`` extracted from the first two tokens of *name*.513 514        Raises515        ------516        ValueError517            If *name* contains fewer than two whitespace-delimited tokens.518        """519        parts = name.split()520        if len(parts) < 2:521            raise ValueError(522                f"Expected at least two tokens in scientific name, got {name!r}"523            )524        return parts[0], parts[1]525 526    def _format_row(527        self,528        api_name: str,529        genus: str,530        species: str,531        api_internal_id: str,532        kingdom: str = _UNSET,  # type: ignore[assignment]533        phylum: str = _UNSET,  # type: ignore[assignment]534        class_: str = _UNSET,  # type: ignore[assignment]535        order: str = _UNSET,  # type: ignore[assignment]536        family: str = _UNSET,  # type: ignore[assignment]537        subfamily: str = _UNSET,  # type: ignore[assignment]538        author: str = _UNSET,  # type: ignore[assignment]539        publication_name: str = _UNSET,  # type: ignore[assignment]540        publication_year: str = _UNSET,  # type: ignore[assignment]541        status: str = _UNSET,  # type: ignore[assignment]542        original_source: str = _UNSET,  # type: ignore[assignment]543        api_link: str = _UNSET,  # type: ignore[assignment]544    ) -> dict:545        """546        Construct a validated pipeline-standard row record.547 548        Parameters549        ----------550        api_name : str551            The name of the API source (e.g. ``"GBIF"``). Must be one of the552            recognised values in ``schema._API_NAMES``.553        genus : str554            Taxonomic genus (single word, no whitespace).555        species : str556            Taxonomic species epithet (single word, no whitespace).557        api_internal_id : str558            Unique identifier for this record in the source database.559        kingdom, phylum, class_, family, subfamily : str, optional560            Taxonomic rank values. Each must be a single word. Use ``class_``561            for the class rank (``"class"`` is a Python keyword).562        author : str, optional563            Authorship string (e.g. ``"(L.) Lam."``).564        publication_name : str, optional565            Full publication citation string.566        publication_year : str, optional567            Four-digit publication year (e.g. ``"1783"``).568        status : str, optional569            Taxonomic status — ``"Accepted"``, ``"Synonym"``, or omit to570            leave as ``UNAVAILABLE``.571        original_source : str, optional572            Name of the original data source cited by the API.573        api_link : str, optional574            Direct URL to the taxon record in the source database.575 576        Returns577        -------578        dict579            A fully validated schema row with all columns from580            ``SYNONYM_COLUMNS``.581        """582        optional = {583            "kingdom": kingdom,584            "phylum": phylum,585            "class": class_,586            "order": order,587            "family": family,588            "subfamily": subfamily,589            "author": author,590            "publication_name": publication_name,591            "publication_year": publication_year,592            "status": status,593            "original_source": original_source,594            "api_link": api_link,595        }596        provided = {k: v for k, v in optional.items() if not isinstance(v, _Unset)}597        return make_synonym_row(598            api_name=api_name,599            genus=genus,600            species=species,601            api_internal_id=api_internal_id,602            **provided,603        )604 605    # ------------------------------------------------------------------606    # ID methods (not required, but one or the other is likely needed for most children)607    # ------------------------------------------------------------------608 609    def _extract_internal_id(self, raw_data: dict | list | str | ET.Element) -> str:610        """611        Resolve raw API response data to the source's internal database identifier.612 613        Parameters614        ----------615        raw_data : any616            The raw response data returned by the source API (type varies by617            subclass).618 619        Returns620        -------621        str622            The internal database identifier for the queried taxon.623 624        Raises625        ------626        NotImplementedError627            When the child class has not provided an implementation for this step.628        LookupError629            When the name cannot be resolved to an identifier.630        """631        raise NotImplementedError(632            f"{type(self).__name__} does not implement _extract_internal_id()."633        )634 635    def _extract_internal_accepted_id(636        self, raw_data: dict | list | str | ET.Element637    ) -> str:638        """639        Extract the internal identifier of the accepted taxon from API response data.640 641        An *accepted* taxon is the currently valid name that a synonym refers to.642        For some APIs the full synonym list is only accessible via the accepted643        name's record, so this method is needed when the initial search result644        may itself be a synonym.645 646        Parameters647        ----------648        raw_data : any649            Parsed API response data (type varies by subclass — e.g., ``dict``650            for GBIF, ``list`` for COL).651 652        Returns653        -------654        any655            The accepted taxon's internal identifier (type varies by subclass).656 657        Raises658        ------659        NotImplementedError660            When the child class has not provided an implementation.661        """662        raise NotImplementedError(663            f"{type(self).__name__} does not implement _extract_internal_accepted_id()."664        )665 666    # ------------------------------------------------------------------667    # Required methods (must be implemented by all children)668    # ------------------------------------------------------------------669 670    @abstractmethod671    def _fetch_query_data(self, name: str) -> dict | list | str | ET.Element:672        """673        Query the source for *name* and return the raw response.674 675        Parameters676        ----------677        name : str678            The scientific name to search (e.g. ``"Amanita muscaria"``).679 680        Returns681        -------682        dict or list or str or xml.etree.ElementTree.Element683            Raw query data in the source's native format (varies by subclass).684        """685        pass686 687    @abstractmethod688    def _fetch_synonym_data(689        self, raw_data: dict | list | ET.Element | str690    ) -> dict | list | str | ET.Element:691        """692        Fetch synonym records for the taxon resolved from *raw_data*.693 694        For sources that list synonyms under an accepted-name endpoint, this695        method extracts the accepted taxon's internal identifier from696        *raw_data* and issues a second request.697 698        Parameters699        ----------700        raw_data : dict or list or str or xml.etree.ElementTree.Element701            The response returned by ``_fetch_query_data``.702 703        Returns704        -------705        dict or list or str or xml.etree.ElementTree.Element706            Raw synonym data in the source's native format (varies by subclass).707        """708        pass709 710    @abstractmethod711    def _fetch_accepted_data(712        self,713        raw_data: dict | list | str | ET.Element,714        synonym_data: dict | list | str | ET.Element,715    ) -> dict | list | str | ET.Element:716        """717        Fetch metadata for the accepted name.718 719        Parameters720        ----------721        raw_data : dict or list or str or xml.etree.ElementTree.Element722            The response returned by ``_fetch_query_data``.723        synonym_data : dict or list or str or xml.etree.ElementTree.Element724            The response returned by ``_fetch_synonym_data``.725 726        Returns727        -------728        dict or list or str or xml.etree.ElementTree.Element729            Raw search term data in the source's native format (varies by subclass).730        """731        pass732 733    @abstractmethod734    def _compile_synonyms(735        self, synonym_data: dict | list | str | ET.Element736    ) -> list[dict]:737        """738        Convert raw synonym data into pipeline-standard synonym records.739 740        Parameters741        ----------742        synonym_data : dict or list or str or xml.etree.ElementTree.Element743            Raw synonym data as returned by ``_fetch_synonym_data``744            (type varies by subclass).745 746        Returns747        -------748        list of dict749            Pipeline-standard synonym records, each produced by ``_format_row``.750        """751        pass752 753    @abstractmethod754    def _compile_accepted(755        self, accepted_data: dict | list | str | ET.Element756    ) -> list[dict]:757        """758        Convert raw search term data into a one-item pipeline-standard record.759 760        Parameters761        ----------762        accepted_data : dict or list or str or xml.etree.ElementTree.Element763            Raw accepted as returned by764            ``_fetch_accepted_data`` (type varies by subclass).765 766        Returns767        -------768        list of dict769            A one-item list with the search term record, or ``[]`` if the name770            cannot be determined from ``accepted_data``.771        """772        pass773 774    # ------------------------------------------------------------------775    # Public methods (used by external callers, can be overrriden by children if needed but should work for most children as-is)776    # ------------------------------------------------------------------777 778    def get_synonyms(self, name: str) -> pd.DataFrame:779        """780        Retrieve taxonomic synonyms and publication metadata for a species name.781 782        Orchestrates the full pipeline: normalize the input, fetch raw query783        data, fetch synonym data, fetch accepted data (including taxonomy), and compile results into the standard format.784 785        This is the only public method and the main entry point for callers.786 787        Parameters788        ----------789        name : str790            The species name search query (e.g. ``"Amanita muscaria"``).791 792        Returns793        -------794        pd.DataFrame795            A DataFrame of accepted and synonym records in schema format, or an empty796            schema-format DataFrame if no results are found.797        """798        name = normalize_query_string(name)799 800        raw_data = self._fetch_query_data(name)801        if self._is_empty(raw_data):802            self._warn_if_blank("_fetch_query_data", raw_data)803            return empty_synonym_table()804 805        synonym_data = self._fetch_synonym_data(raw_data)806        self._warn_if_blank("_fetch_synonym_data", synonym_data)807 808        accepted_data = self._fetch_accepted_data(raw_data, synonym_data)809        self._warn_if_blank("_fetch_accepted_data", accepted_data)810 811        accepted = []812        synonyms = []813 814        # Compile accepted and synonym records only if their respective raw data is not empty.815        if not self._is_empty(synonym_data):816            synonyms = self._compile_synonyms(synonym_data)817        if not self._is_empty(accepted_data):818            accepted = self._compile_accepted(accepted_data)819 820        rows = accepted + synonyms821        if not rows:822            return empty_synonym_table()823        return pd.DataFrame(rows)824