wfr2/species-synonym-api
0
1import re2 3import pandas as pd4 5from scripts.config import ALL_PORTALS6 7# value to use when data is unavailable (i.e. never present) from a given API source. This is not the same as an empty string, which indicates that the data was not found for that particular query (e.g. no author for a given taxonomic name).8UNAVAILABLE = "N/A"9 10SYNONYM_COLUMNS = [11 "api_name", # name of API source that provided the data, e.g. GBIF (required)12 "kingdom", # taxonomic kingdom (optional)13 "phylum", # taxonomic phylum (optional)14 "class", # taxonomic class (optional)15 "family", # taxonomic family (optional)16 "order", # taxonomic order (optional)17 "subfamily", # taxonomic subfamily (optional)18 "genus", # taxonomic genus (required)19 "species", # taxonomic species (required)20 "author", # author of the taxonomic name (optional)21 "publication_name", # name of the publication where the taxonomic name was published (optional)22 "publication_year", # year when the taxonomic name was published (optional)23 "status", # status of the taxonomic name in the API source's database, either "Accepted" or "Synonym" (optional)24 "original_source", # name of the source that provided the data to the API source, e.g. a cited journal article, museum collection, etc. (optional)25 "api_link", # link to the search result on the API source's website (optional)26 "api_internal_id", # unique identifier for the record of this taxonomic name in the API source's database (required)27]28 29# valid values for the "status" column, including UNAVAILABLE to indicate that the API source does not provide this information30_STATUS_VALUES = {31 "Accepted",32 "Synonym",33 "",34 UNAVAILABLE,35}36 37# columns that represent taxonomic ranks and must be single words (no whitespace) or UNAVAILABLE38_TAXON_COLUMNS = {39 "kingdom",40 "phylum",41 "class",42 "order",43 "family",44 "subfamily",45 "genus",46 "species",47}48 49# columns that must be strings (can be empty or UNAVAILABLE, but not other types)50_STRING_COLUMNS = {51 "api_internal_id",52 "original_source",53 "author",54 "publication_name",55}56 57# columns that must be explicitly provided (cannot be UNAVAILABLE)58_REQUIRED_COLUMNS = {59 "api_name",60 "genus",61 "species",62 "api_internal_id",63}64 65_API_NAMES: set[str] = {p.display_name for p in ALL_PORTALS}66 67 68def _make_string_validator(col: str):69 """70 Create a validator function that checks if a value is a string.71 72 Parameters73 ----------74 col : str75 Column name, used in the error message.76 77 Returns78 -------79 callable80 Validator that raises ``ValueError`` if the value is not a string.81 """82 83 def validate(v) -> None:84 if not isinstance(v, str):85 raise ValueError(f"'{col}' must be a string, got {v!r}")86 87 return validate88 89 90def _make_taxon_validator(col: str):91 """92 Create a validator function that checks if a value is a single word or ``UNAVAILABLE``. All taxon entries must be a single word (no whitespace) or ``UNAVAILABLE``.93 94 Parameters95 ----------96 col : str97 Column name, used in the error message.98 99 Returns100 -------101 callable102 Validator that raises ``ValueError`` if the value is not a string, contains103 whitespace, or is not equal to ``UNAVAILABLE``.104 """105 106 def validate(v: str) -> None:107 if not isinstance(v, str) or (v != UNAVAILABLE and re.search(r"\s", v)):108 raise ValueError(109 f"'{col}' must be a single word (no whitespace) or {UNAVAILABLE!r}, got {v!r}"110 )111 112 return validate113 114 115def _validate_api_name(v: str) -> None:116 """117 Validate that an api name is one of the allowed values in ``_API_NAMES``.118 119 Parameters120 ----------121 v : str122 Value to validate.123 124 Raises125 ------126 ValueError127 If ``v`` is not in ``_API_NAMES``.128 """129 if v not in _API_NAMES:130 raise ValueError(f"'api_name' must be one of {_API_NAMES!r}, got {v!r}")131 132 133def _validate_publication_year(v: str) -> None:134 """135 Validate that a publication year is a 4-digit year string, ``""``, or ``UNAVAILABLE``.136 137 Parameters138 ----------139 v : str140 Value to validate.141 142 Raises143 ------144 ValueError145 If ``v`` is not a 4-digit numeric string, ``""``, or ``UNAVAILABLE``.146 """147 if v in (UNAVAILABLE, ""):148 return149 if not re.fullmatch(r"\d{4}", v):150 raise ValueError(151 f"'publication_year' must be a 4-digit numeric string, an empty string, or {UNAVAILABLE!r}, got {v!r}"152 )153 154 155def _validate_api_link(v: str) -> None:156 """157 Validate that an api link starts with ``http://`` or ``https://``, or is ``UNAVAILABLE``.158 159 Parameters160 ----------161 v : str162 Value to validate.163 164 Raises165 ------166 ValueError167 If ``v`` does not start with ``http://`` or ``https://`` and is not equal to ``UNAVAILABLE``.168 """169 if v != UNAVAILABLE and v != "" and not re.match(r"https?://", v):170 raise ValueError(171 f"'api_link' must start with 'http://' or 'https://', or be {UNAVAILABLE!r}, got {v!r}"172 )173 174 175def _validate_status(v: str) -> None:176 """177 Validate that a status value is one of the allowed values in ``_STATUS_VALUES``.178 179 Parameters180 ----------181 v : str182 Value to validate.183 184 Raises185 ------186 ValueError187 If ``v`` is not in ``_STATUS_VALUES``.188 """189 if v not in _STATUS_VALUES:190 raise ValueError(f"'status' must be one of {_STATUS_VALUES}, got {v!r}")191 192 193# mapping of column name to validator function for validating synonym row values. All columns have validators, but some share the same validator (e.g. all taxon columns use the same taxon validator factory).194_VALIDATORS = {195 "api_name": _validate_api_name,196 "publication_year": _validate_publication_year,197 "api_link": _validate_api_link,198 "status": _validate_status,199 **{col: _make_taxon_validator(col) for col in _TAXON_COLUMNS},200 **{col: _make_string_validator(col) for col in _STRING_COLUMNS},201}202 203 204def empty_synonym_table() -> pd.DataFrame:205 """206 Create an empty DataFrame with the synonym table columns.207 208 Returns209 -------210 pd.DataFrame211 Empty DataFrame with columns defined by ``SYNONYM_COLUMNS``.212 """213 return pd.DataFrame(columns=SYNONYM_COLUMNS)214 215 216def make_synonym_row(**kwargs) -> dict:217 """218 Build a validated synonym row dict with all columns from ``SYNONYM_COLUMNS``.219 220 Any column not provided in ``kwargs`` defaults to ``UNAVAILABLE``. Required221 columns (``_REQUIRED_COLUMNS``) must be explicitly provided and cannot be222 ``UNAVAILABLE``. All values are validated against ``_VALIDATORS`` before the223 row is returned.224 225 Parameters226 ----------227 **kwargs228 Column values keyed by column name. Valid keys are those in ``SYNONYM_COLUMNS``.229 230 Returns231 -------232 dict233 Mapping of column name to value for all columns in ``SYNONYM_COLUMNS``.234 235 Raises236 ------237 ValueError238 If a required column is missing or set to ``UNAVAILABLE``, or if any239 column value fails its validator.240 """241 # Validate that a blank string, "", was used for any passed value that did not have an entry, not None or "N/A"242 for col, v in kwargs.items():243 if v is None:244 raise TypeError(245 f"Got None for '{col}' in make_synonym_row. "246 f"Pass '' if the field was searched but not found, "247 f"or omit the argument if the API does not provide this field."248 )249 if isinstance(v, str) and v == UNAVAILABLE:250 raise ValueError(251 f"Got {UNAVAILABLE!r} for '{col}' in make_synonym_row. "252 f"Do not pass {UNAVAILABLE!r} directly — "253 f"omit the argument to let make_synonym_row apply the default."254 )255 row = {col: kwargs.get(col, UNAVAILABLE) for col in SYNONYM_COLUMNS}256 missing = [col for col in _REQUIRED_COLUMNS if row[col] in (UNAVAILABLE, "")]257 if missing:258 raise ValueError(f"Missing required columns: {sorted(missing)}")259 for col, validate in _VALIDATORS.items():260 validate(row[col])261 return row262 