diff --git a/CHANGELOG.md b/CHANGELOG.md index 094ada7..50def66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,42 @@ All notable changes to the USDA FDC Python Client will be documented in this file. +## [Unreleased] + +### Added +- `status_code` on `FdcApiError` and every exception deriving from it, holding + the HTTP status the API replied with (`None` when the request never reached + the API, as with a refused connection or a timeout). The documentation has + told callers to branch on this attribute for some time, including a + retry-on-5xx example that could never have run: nothing ever set it, so + `hasattr(e, 'status_code')` was always `False`. +- `get_dri_value`, which returns a DRI together with its unit. +- `FdcValidationError` and `FdcResourceNotFoundError` are now exported from the + package root, alongside the exceptions that were already there. + +### Fixed +- **An invalid API key raised a nondescript `FdcApiError`.** FDC sits behind + api.data.gov, which rejects a bad key with HTTP **403**, while only 401 was + being mapped — so the single most common mistake a caller can make missed + `FdcAuthError` entirely. Both statuses now raise it. +- **A missing food and a rejected request were indistinguishable from a broken + API.** HTTP 404 now raises `FdcResourceNotFoundError` and HTTP 400 raises + `FdcValidationError`. Both classes had been defined, documented, and never + raised by anything. +- **Only `DriType.RDA` ever returned data.** `ul.json` ships real Tolerable + Upper Intake Levels, but under a different schema than `get_dri` knew how to + read, so every UL lookup came back `None`. Both schemas are now understood. + `DriType.AI`, `EAR` and `AMDR` ship no data at all; they still return `None`, + but now log a warning saying so once, rather than leaving an empty DRI column + unexplained. +- **DRI percentages ignored units.** The shipped data does not use one scale + throughout: iron's RDA is `8` mg, its UL is `0.045` **g**. `analyze_food` + divided a food's amount by the DRI as a bare number, so a serving of spinach + measured against iron's upper limit would have reported **2802%** of it + instead of 2.8%. Amounts are now converted into the DRI's unit first, and a + pair that cannot be compared at all — vitamin A in IU against a µg allowance — + yields no percentage rather than a confident wrong one. + ## [0.1.11] - 2026-07-13 ### Security diff --git a/docs/user/error_handling.rst b/docs/user/error_handling.rst index ac5299c..2ff312b 100644 --- a/docs/user/error_handling.rst +++ b/docs/user/error_handling.rst @@ -8,12 +8,20 @@ Exception Hierarchy The library defines the following exception hierarchy: -- ``FdcApiError``: Base exception for all API errors - - ``FdcAuthError``: Authentication failed (invalid API key) - - ``FdcRateLimitError``: API rate limit exceeded +- ``FdcApiError``: Base exception for all API errors (HTTP 5xx and anything unmapped) + - ``FdcAuthError``: Authentication failed — HTTP 401 or 403. FDC sits behind + api.data.gov, which rejects an invalid key with **403**, so both are treated + as an auth failure. + - ``FdcRateLimitError``: API rate limit exceeded — HTTP 429 - ``FdcTimeoutError``: The API did not respond within the client timeout - - ``FdcValidationError``: Invalid input parameters - - ``FdcResourceNotFoundError``: Requested resource not found + - ``FdcValidationError``: The API rejected the request — HTTP 400, typically a + parameter outside the range FDC accepts (``page_size`` above 200, say) + - ``FdcResourceNotFoundError``: Requested resource not found — HTTP 404. A food + that does not exist is an ordinary outcome of a lookup, not a breakdown, so + it is worth catching on its own. + +Every one of these is an ``FdcApiError``, so a broad ``except FdcApiError`` still +catches the lot. Request Timeouts ---------------- @@ -63,28 +71,27 @@ Here's how to handle errors when using the client: Handling Specific HTTP Status Codes -------------------------------- -The ``FdcApiError`` exception includes the HTTP status code, which you can use for more specific error handling: +Every ``FdcApiError`` carries the HTTP status the API replied with, as +``status_code``. It is ``None`` for failures that never reached the API — a +refused connection, a timeout — which is itself worth knowing: .. code-block:: python - from usda_fdc import FdcClient, FdcApiError - + from usda_fdc import FdcClient, FdcApiError, FdcResourceNotFoundError + client = FdcClient(api_key="your_api_key_here") - + try: food = client.get_food(1750340) + except FdcResourceNotFoundError: + print("No such food") # usually clearer than reading the status except FdcApiError as e: - if hasattr(e, 'status_code'): - if e.status_code == 404: - print("Food not found") - elif e.status_code == 429: - print("Too many requests. Try again later.") - elif e.status_code >= 500: - print("Server error. Try again later.") - else: - print(f"API error: {e}") + if e.status_code is None: + print(f"Never reached the API: {e}") + elif e.status_code >= 500: + print("Server error. Try again later.") else: - print(f"API error without status code: {e}") + print(f"API error {e.status_code}: {e}") Retry Logic --------- @@ -94,25 +101,26 @@ For transient errors like rate limiting or server errors, you can implement retr .. code-block:: python import time - from usda_fdc import FdcClient, FdcApiError, FdcRateLimitError - + from usda_fdc import FdcClient, FdcApiError, FdcRateLimitError, FdcTimeoutError + client = FdcClient(api_key="your_api_key_here") - + def get_food_with_retry(fdc_id, max_retries=3, retry_delay=5): retries = 0 while retries < max_retries: try: return client.get_food(fdc_id) - except FdcRateLimitError: + except (FdcRateLimitError, FdcTimeoutError): + # Both are "try again", not "you asked for the wrong thing" retries += 1 if retries < max_retries: - print(f"Rate limit exceeded. Retrying in {retry_delay} seconds...") + print(f"Throttled or timed out. Retrying in {retry_delay} seconds...") time.sleep(retry_delay) retry_delay *= 2 # Exponential backoff else: raise except FdcApiError as e: - if hasattr(e, 'status_code') and e.status_code >= 500: + if e.status_code is not None and e.status_code >= 500: retries += 1 if retries < max_retries: print(f"Server error. Retrying in {retry_delay} seconds...") @@ -121,6 +129,7 @@ For transient errors like rate limiting or server errors, you can implement retr else: raise else: + # A 400 or a 404 will not fix itself raise # Use the retry function diff --git a/docs/user/nutrient_analysis.rst b/docs/user/nutrient_analysis.rst index 020c257..b1ea1e4 100644 --- a/docs/user/nutrient_analysis.rst +++ b/docs/user/nutrient_analysis.rst @@ -175,29 +175,46 @@ The HTML report includes: Dietary Reference Intakes (DRIs) ----------------------------- -The library includes data for various types of Dietary Reference Intakes: +The library ships Dietary Reference Intake data drawn from the Institute of +Medicine's *Dietary Reference Intakes: The Essential Guide to Nutrient +Requirements* (2006). .. code-block:: python - from usda_fdc.analysis.dri import get_dri, DriType, Gender + from usda_fdc.analysis.dri import get_dri_value, DriType, Gender # Get the RDA for protein for a 30-year-old male - protein_rda = get_dri( + protein_rda = get_dri_value( nutrient_id="protein", dri_type=DriType.RDA, gender=Gender.MALE, age=30 ) - - print(f"Protein RDA: {protein_rda}g") + + print(f"Protein RDA: {protein_rda.value}{protein_rda.unit}") # 56g + +Mind the unit. ``get_dri_value`` returns it alongside the number because the +underlying data does not use one scale throughout: iron's RDA is ``8`` **mg** +while its UL is ``0.045`` **g**. Comparing a food's milligrams against the +latter would overstate it a thousandfold. ``analyze_food`` converts the food's +amount into the DRI's unit before working out ``dri_percent``, and reports no +percentage at all where the two cannot be compared — vitamin A in IU against a +µg allowance, for instance. + +``get_dri`` still returns the bare number, in whatever unit that DRI type's data +uses. Available DRI types: -- ``DriType.RDA``: Recommended Dietary Allowance -- ``DriType.AI``: Adequate Intake -- ``DriType.UL``: Tolerable Upper Intake Level -- ``DriType.EAR``: Estimated Average Requirement -- ``DriType.AMDR``: Acceptable Macronutrient Distribution Range +- ``DriType.RDA``: Recommended Dietary Allowance — **data included** +- ``DriType.UL``: Tolerable Upper Intake Level — **data included** +- ``DriType.AI``: Adequate Intake — no data; lookups return ``None`` +- ``DriType.EAR``: Estimated Average Requirement — no data; lookups return ``None`` +- ``DriType.AMDR``: Acceptable Macronutrient Distribution Range — no data; lookups return ``None`` + +Asking for a type with no data behind it logs a warning once and returns +``None``, so an empty DRI column has a stated reason rather than being a +mystery. Command-Line Interface ------------------- diff --git a/tests/unit/test_dri.py b/tests/unit/test_dri.py new file mode 100644 index 0000000..87288e1 --- /dev/null +++ b/tests/unit/test_dri.py @@ -0,0 +1,116 @@ +""" +Tests for DRI lookup and the unit arithmetic around it. + +Two schemas ship in this package. rda.json keys nutrients directly and states a +natural unit for each (iron in mg). ul.json, rda_male.json and rda_female.json +wrap a flat table in metadata and express everything in grams (iron 0.045 = 45 +mg). Only the first was ever read, so every DriType except RDA silently returned +None — and a gram-denominated DRI compared against a food's milligrams would be +off by a factor of a thousand. +""" + +import pytest + +from usda_fdc.models import Food, Nutrient +from usda_fdc.analysis import analyze_food +from usda_fdc.analysis.dri import DriType, Gender, get_dri, get_dri_value + + +def _food_with(nutrient: Nutrient) -> Food: + return Food(fdc_id=1, description="Spinach, baby", data_type="Foundation", + nutrients=[nutrient]) + + +IRON_MG = Nutrient(id=1089, name="Iron, Fe", amount=1.261, unit_name="mg", nutrient_nbr="303") + + +def test_rda_is_found_with_its_unit(): + dri = get_dri_value("iron", DriType.RDA, Gender.MALE, 30) + + assert dri.value == 8 + assert dri.unit == "mg" + + +def test_upper_intake_limits_are_found(): + """The regression: ul.json ships real data, but its schema was never read, + so every UL lookup came back None.""" + dri = get_dri_value("iron", DriType.UL, Gender.MALE, 30) + + assert dri is not None + assert dri.value == 0.045 + assert dri.unit == "g" # this file is grams throughout: 45 mg + + +def test_upper_intake_limits_apply_to_both_genders(): + """ul.json declares gender "all".""" + for gender in (Gender.MALE, Gender.FEMALE): + assert get_dri_value("calcium", DriType.UL, gender, 30) is not None + + +def test_a_dri_type_with_no_data_returns_none_rather_than_guessing(): + assert get_dri_value("iron", DriType.AI, Gender.MALE, 30) is None + assert get_dri_value("iron", DriType.EAR, Gender.MALE, 30) is None + + +def test_a_missing_dri_type_is_reported_once(caplog): + """Silence was the bug: every AI lookup returned None with no hint why.""" + from usda_fdc.analysis import dri as dri_module + dri_module._warned_missing.discard(DriType.AI.value) + dri_module._dri_cache.pop(DriType.AI.value, None) + + with caplog.at_level("WARNING"): + get_dri_value("iron", DriType.AI, Gender.MALE, 30) + get_dri_value("calcium", DriType.AI, Gender.MALE, 30) + + warnings = [r for r in caplog.records if "No DRI data" in r.message] + assert len(warnings) == 1 + + +def test_rda_percentage_is_computed_against_the_rda_unit(): + analysis = analyze_food(_food_with(IRON_MG), serving_size=100.0, dri_type=DriType.RDA) + + iron = analysis.get_nutrient("iron") + assert iron.dri_percent == pytest.approx(1.261 / 8 * 100, rel=1e-6) + + +def test_a_gram_denominated_dri_is_not_compared_against_milligrams(): + """The unit trap: iron's UL is 0.045 **g**. Dividing 1.261 mg by 0.045 + straight would report 2802% of the upper limit for a portion of spinach. + The real answer is 1.261 mg / 45 mg.""" + analysis = analyze_food(_food_with(IRON_MG), serving_size=100.0, dri_type=DriType.UL) + + iron = analysis.get_nutrient("iron") + assert iron.dri_percent == pytest.approx(1.261 / 45.0 * 100, rel=1e-6) + assert iron.dri_percent < 5.0 + + +def test_the_dri_unit_is_exposed_alongside_the_value(): + analysis = analyze_food(_food_with(IRON_MG), serving_size=100.0, dri_type=DriType.UL) + + iron = analysis.get_nutrient("iron") + assert iron.dri == 0.045 + assert iron.dri_unit == "g" + + +def test_incomparable_units_yield_no_percentage_rather_than_a_wrong_one(): + """Vitamin A in IU cannot be checked against a µg allowance. Reporting + nothing beats reporting a confident wrong number.""" + vitamin_a_iu = Nutrient(id=1104, name="Vitamin A, IU", amount=9377.0, + unit_name="IU", nutrient_nbr="318") + + analysis = analyze_food(_food_with(vitamin_a_iu), serving_size=100.0) + + vitamin_a = analysis.get_nutrient("vitamin_a") + assert vitamin_a.dri_percent is None + + +def test_get_dri_still_returns_a_bare_number(): + """The old signature stays, for callers already using it.""" + assert get_dri("iron", DriType.RDA, Gender.MALE, 30) == 8 + + +def test_age_and_gender_still_select_the_right_rda(): + """Iron: 18 mg for women of 19-50, 8 mg after.""" + assert get_dri("iron", DriType.RDA, Gender.FEMALE, 30) == 18 + assert get_dri("iron", DriType.RDA, Gender.FEMALE, 60) == 8 + assert get_dri("iron", DriType.RDA, Gender.MALE, 30) == 8 diff --git a/tests/unit/test_error_mapping.py b/tests/unit/test_error_mapping.py new file mode 100644 index 0000000..a491be3 --- /dev/null +++ b/tests/unit/test_error_mapping.py @@ -0,0 +1,124 @@ +""" +Tests for how HTTP failures become exceptions. + +The docs have long told callers to branch on ``e.status_code`` and to retry on +5xx — but nothing ever set that attribute, so the documented retry loop could +never fire. FdcResourceNotFoundError and FdcValidationError were defined, +documented, and never raised: a missing food arrived as a nondescript +FdcApiError. And FDC sits behind api.data.gov, which rejects a bad key with 403 +rather than 401, so the single most common auth failure missed FdcAuthError too. +""" + +import pytest +from unittest.mock import MagicMock, patch + +import requests + +from usda_fdc import ( + FdcClient, + FdcApiError, + FdcAuthError, + FdcRateLimitError, + FdcResourceNotFoundError, + FdcTimeoutError, + FdcValidationError, +) + + +def _client_returning(status: int, body: str = "") -> FdcClient: + client = FdcClient(api_key="test_key") + response = MagicMock(status_code=status, text=body) + response.raise_for_status.side_effect = requests.exceptions.HTTPError(str(status)) + client.session.request = MagicMock(return_value=response) + return client + + +@pytest.mark.parametrize("status,expected", [ + (400, FdcValidationError), + (401, FdcAuthError), + (403, FdcAuthError), # api.data.gov's answer to a bad key + (404, FdcResourceNotFoundError), + (429, FdcRateLimitError), + (500, FdcApiError), +]) +def test_status_maps_to_its_exception(status, expected): + client = _client_returning(status) + + with pytest.raises(expected): + client._make_request("food/1") + + +@pytest.mark.parametrize("status", [400, 401, 403, 404, 429, 500, 503]) +def test_status_code_is_carried_on_the_exception(status): + """The docs tell callers to branch on this; it has to exist.""" + client = _client_returning(status) + + with pytest.raises(FdcApiError) as exc: + client._make_request("food/1") + + assert exc.value.status_code == status + + +def test_a_bad_key_is_an_auth_error_not_a_mystery(): + """FDC answers an invalid key with 403. Catching only 401 meant the most + common mistake a caller can make surfaced as a generic API error.""" + client = _client_returning(403, "API_KEY_INVALID") + + with pytest.raises(FdcAuthError) as exc: + client._make_request("food/1") + + assert exc.value.status_code == 403 + + +def test_a_missing_food_is_distinguishable_from_a_broken_api(): + """A food that does not exist is a normal outcome of a lookup, not a + breakdown, and a caller must be able to tell the two apart.""" + client = _client_returning(404) + + with pytest.raises(FdcResourceNotFoundError): + client._make_request("food/999999999") + + +def test_the_documented_retry_on_5xx_is_reachable(): + """Verbatim the shape of the retry example in docs/user/error_handling.rst, + which could never have run: hasattr(e, 'status_code') was always False.""" + client = _client_returning(503) + + retried = False + try: + client._make_request("food/1") + except FdcApiError as e: + if e.status_code is not None and e.status_code >= 500: + retried = True + + assert retried + + +def test_failures_that_never_reached_the_api_have_no_status_code(): + client = FdcClient(api_key="test_key") + + with patch.object(client.session, "request", + side_effect=requests.exceptions.ConnectionError("refused")): + with pytest.raises(FdcApiError) as exc: + client._make_request("food/1") + + assert exc.value.status_code is None + + +def test_a_timeout_has_no_status_code_either(): + client = FdcClient(api_key="test_key", timeout=0.1) + + with patch.object(client.session, "request", + side_effect=requests.exceptions.Timeout("timed out")): + with pytest.raises(FdcTimeoutError) as exc: + client._make_request("food/1") + + assert exc.value.status_code is None + + +def test_every_error_is_still_catchable_as_the_base_class(): + """Callers with a broad `except FdcApiError` must keep working.""" + for status in (400, 401, 403, 404, 429, 500): + client = _client_returning(status) + with pytest.raises(FdcApiError): + client._make_request("food/1") diff --git a/usda_fdc/__init__.py b/usda_fdc/__init__.py index fcb9730..ef26307 100644 --- a/usda_fdc/__init__.py +++ b/usda_fdc/__init__.py @@ -8,7 +8,14 @@ __version__ = "0.1.11" from .client import FdcClient -from .exceptions import FdcApiError, FdcRateLimitError, FdcAuthError, FdcTimeoutError +from .exceptions import ( + FdcApiError, + FdcRateLimitError, + FdcAuthError, + FdcTimeoutError, + FdcValidationError, + FdcResourceNotFoundError, +) __all__ = [ "FdcClient", @@ -16,4 +23,6 @@ "FdcRateLimitError", "FdcAuthError", "FdcTimeoutError", + "FdcValidationError", + "FdcResourceNotFoundError", ] \ No newline at end of file diff --git a/usda_fdc/analysis/analysis.py b/usda_fdc/analysis/analysis.py index b0b1f8d..f3c57bd 100644 --- a/usda_fdc/analysis/analysis.py +++ b/usda_fdc/analysis/analysis.py @@ -6,7 +6,43 @@ from typing import Dict, List, Optional, Any, Union, Tuple from ..models import Food, Nutrient -from .dri import DriType, Gender, get_dri +from ..utils import convert_measurement +from .dri import DriType, DriValue, Gender, get_dri, get_dri_value + +# FDC writes its units in caps ("MG"), and micrograms as "UG". Anything not +# listed here — IU above all — has no mass dimension we can convert. +_FDC_UNIT_ALIASES = { + "g": "g", + "mg": "mg", + "ug": "µg", + "µg": "µg", + "mcg": "µg", +} + + +def _dri_percent(amount: float, unit: Optional[str], dri: Optional[DriValue]) -> Optional[float]: + """What percentage of a DRI an amount represents, or None if they cannot be compared. + + The DRI files disagree about units: an RDA for iron is 8 mg, its UL is + 0.045 — grams. Dividing a food's milligrams by the latter would report 1000x + the truth, so the amount is converted into the DRI's own unit first, and a + pair that cannot be converted (vitamin A in IU against a µg allowance) + yields nothing rather than a confident wrong number. + """ + if dri is None or not dri.value: + return None + + food_unit = _FDC_UNIT_ALIASES.get((unit or "").strip().lower()) + dri_unit = _FDC_UNIT_ALIASES.get((dri.unit or "").strip().lower()) + if not food_unit or not dri_unit: + return None + + try: + comparable = convert_measurement(amount, food_unit, dri_unit) + except ValueError: + return None + + return (comparable / dri.value) * 100.0 @dataclass class NutrientValue: @@ -19,6 +55,9 @@ class NutrientValue: dri: Optional[float] = None dri_percent: Optional[float] = None dri_type: Optional[DriType] = None + # The unit ``dri`` is expressed in, which is not always the food's own: + # an RDA for iron is 8 mg, its UL is 0.045 g. + dri_unit: Optional[str] = None @dataclass class NutrientAnalysis: @@ -173,22 +212,18 @@ def analyze_food( # Calculate amount for the serving size amount = nutrient.amount * (serving_size / 100.0) - # Get DRI value if available - dri_value = get_dri(nutrient_id, dri_type, gender, age) - - # Calculate DRI percentage if DRI is available - dri_percent = None - if dri_value is not None and dri_value > 0: - dri_percent = (amount / dri_value) * 100.0 + # Get DRI value if available, and the unit it is expressed in + dri = get_dri_value(nutrient_id, dri_type, gender, age) # Create nutrient value nutrient_value = NutrientValue( nutrient=nutrient, amount=amount, unit=nutrient.unit_name, - dri=dri_value, - dri_percent=dri_percent, - dri_type=dri_type + dri=dri.value if dri else None, + dri_percent=_dri_percent(amount, nutrient.unit_name, dri), + dri_type=dri_type, + dri_unit=dri.unit if dri else None ) if nutrient_id == "calories": diff --git a/usda_fdc/analysis/dri.py b/usda_fdc/analysis/dri.py index 0740317..2301de6 100644 --- a/usda_fdc/analysis/dri.py +++ b/usda_fdc/analysis/dri.py @@ -4,8 +4,11 @@ import os import json +import logging from enum import Enum -from typing import Dict, Optional, Any, Union +from typing import Dict, NamedTuple, Optional, Any, Union + +logger = logging.getLogger(__name__) # Path to DRI data files DRI_DATA_DIR = os.path.join(os.path.dirname(__file__), "resources", "dri") @@ -16,81 +19,188 @@ class Gender(str, Enum): FEMALE = "female" class DriType(str, Enum): - """Types of Dietary Reference Intakes.""" + """Types of Dietary Reference Intakes. + + Only RDA and UL ship with data. Asking for a type with no data behind it + returns ``None`` and logs a warning — see ``get_dri``. + """ RDA = "rda" # Recommended Dietary Allowance AI = "ai" # Adequate Intake UL = "ul" # Tolerable Upper Intake Level EAR = "ear" # Estimated Average Requirement AMDR = "amdr" # Acceptable Macronutrient Distribution Range -# Cache for DRI data -_dri_cache: Dict[str, Dict] = {} -def _load_dri_data(dri_type: DriType) -> Dict: +class DriValue(NamedTuple): + """A DRI, with the unit it is expressed in. + + The unit is the whole point: the shipped data files disagree about it. + rda.json states a natural unit per nutrient (iron in mg, vitamin A in µg), + while ul.json expresses every nutrient in grams (iron 0.045 = 45 mg). + Handing a caller a bare number invited it to be compared against a food's + milligrams, off by a factor of a thousand and with nothing to show for it. + """ + value: float + unit: str + + +# Any age, for a data file that does not break its values down by age. +_ANY_AGE = "*" + +# Cache for DRI data, normalized to a single shape +_dri_cache: Dict[str, Dict[str, Any]] = {} + +# DRI types we have already warned about, so a missing file is reported once +# rather than once per nutrient per food. +_warned_missing: set = set() + + +def _parse_age_group(age_group: str) -> str: + """Turn a metadata age_group ("19+ years", "19-50 years") into a range key.""" + return age_group.replace("years", "").replace("year", "").strip() + + +def _normalize(raw: Dict[str, Any]) -> Dict[str, Any]: + """Normalize a DRI file into one shape. + + Two schemas ship in this package: + + * ``rda.json`` keys nutrients directly, and breaks each down by gender and + age with its own unit: + ``{"iron": {"male": {"19+": 8}, "female": {...}, "unit": "mg"}}`` + + * ``ul.json``, ``rda_male.json`` and ``rda_female.json`` wrap a flat table + in metadata, apply to one age band, and express everything in grams: + ``{"metadata": {...}, "dietary_reference_intakes": {"iron": 0.045}}`` + + Only the first was ever read, which is why every DriType other than RDA + silently returned None. + """ + table = raw.get("dietary_reference_intakes") + if table is None: + return raw # rda.json shape: already normalized + + metadata = raw.get("metadata", {}) + age_range = _parse_age_group(metadata.get("age_group", "")) or _ANY_AGE + + gender = metadata.get("gender", "all") + genders = [g.value for g in Gender] if gender in ("all", "", None) else [gender] + + normalized: Dict[str, Any] = {} + for nutrient_id, value in table.items(): + entry: Dict[str, Any] = {"unit": "g"} # this schema is grams throughout + for g in genders: + entry[g] = {age_range: value} + normalized[nutrient_id] = entry + + return normalized + + +def _load_dri_data(dri_type: DriType) -> Dict[str, Any]: """ Load DRI data from JSON file. - + Args: dri_type: The type of DRI to load. - + Returns: - Dictionary containing DRI data. + Dictionary containing DRI data, normalized to a single shape. """ if dri_type.value in _dri_cache: return _dri_cache[dri_type.value] - + file_path = os.path.join(DRI_DATA_DIR, f"{dri_type.value}.json") - + try: with open(file_path, 'r') as f: - data = json.load(f) - _dri_cache[dri_type.value] = data - return data + data = _normalize(json.load(f)) except FileNotFoundError: - # Return empty data if file not found - return {} + # No data ships for this type. Say so once, instead of returning None + # forever and leaving the caller to wonder why every DRI is missing. + if dri_type.value not in _warned_missing: + _warned_missing.add(dri_type.value) + logger.warning( + "No DRI data is available for %s; %s comparisons will be empty. " + "This package ships data for %s and %s only.", + dri_type.value.upper(), + dri_type.value.upper(), + DriType.RDA.value.upper(), + DriType.UL.value.upper(), + ) + data = {} -def get_dri( + _dri_cache[dri_type.value] = data + return data + + +def get_dri_value( nutrient_id: Union[str, int], dri_type: DriType = DriType.RDA, gender: Gender = Gender.MALE, age: int = 30 -) -> Optional[float]: +) -> Optional[DriValue]: """ - Get the Dietary Reference Intake (DRI) for a nutrient. - + Get the Dietary Reference Intake (DRI) for a nutrient, with its unit. + + Prefer this over ``get_dri``: the shipped data files express their values in + different units, so a number on its own cannot be compared against a food. + Args: nutrient_id: The nutrient ID or name. dri_type: The type of DRI to retrieve. gender: The gender to use for the DRI. age: The age to use for the DRI. - + Returns: - The DRI value, or None if not found. + The DRI and its unit, or None if there is none for this nutrient. """ - # Convert nutrient_id to string nutrient_id = str(nutrient_id).lower() - - # Load DRI data + dri_data = _load_dri_data(dri_type) - - # Check if nutrient exists in data + if nutrient_id not in dri_data: return None - - # Get age groups for the gender - gender_data = dri_data[nutrient_id].get(gender.value, {}) - - # Find the appropriate age group + + entry = dri_data[nutrient_id] + unit = entry.get("unit", "g") + gender_data = entry.get(gender.value, {}) + for age_range, value in gender_data.items(): - # Parse age range + if age_range == _ANY_AGE: + return DriValue(value, unit) if "-" in age_range: min_age, max_age = map(int, age_range.split("-")) if min_age <= age <= max_age: - return value + return DriValue(value, unit) elif age_range.endswith("+"): - min_age = int(age_range[:-1]) - if age >= min_age: - return value - - return None \ No newline at end of file + if age >= int(age_range[:-1]): + return DriValue(value, unit) + + return None + + +def get_dri( + nutrient_id: Union[str, int], + dri_type: DriType = DriType.RDA, + gender: Gender = Gender.MALE, + age: int = 30 +) -> Optional[float]: + """ + Get the Dietary Reference Intake (DRI) for a nutrient. + + The value is expressed in the unit the underlying data file uses, which is + not the same across DRI types: RDA values come in a natural unit per + nutrient (iron in mg), UL values come in grams (iron 0.045). Use + ``get_dri_value`` to get the unit along with the number. + + Args: + nutrient_id: The nutrient ID or name. + dri_type: The type of DRI to retrieve. + gender: The gender to use for the DRI. + age: The age to use for the DRI. + + Returns: + The DRI value, or None if not found. + """ + dri = get_dri_value(nutrient_id, dri_type, gender, age) + return dri.value if dri else None diff --git a/usda_fdc/client.py b/usda_fdc/client.py index 4212576..7c8e4a4 100644 --- a/usda_fdc/client.py +++ b/usda_fdc/client.py @@ -9,7 +9,14 @@ from urllib.parse import urljoin from dotenv import load_dotenv -from .exceptions import FdcApiError, FdcRateLimitError, FdcAuthError, FdcTimeoutError +from .exceptions import ( + FdcApiError, + FdcRateLimitError, + FdcAuthError, + FdcTimeoutError, + FdcValidationError, + FdcResourceNotFoundError, +) from .models import Food, SearchResult, Nutrient logger = logging.getLogger(__name__) @@ -121,14 +128,28 @@ def _make_request( return response.json() except requests.exceptions.HTTPError as e: - if response.status_code == 401: - raise FdcAuthError("Authentication failed. Check your API key.") from e - elif response.status_code == 429: - raise FdcRateLimitError("Rate limit exceeded.") from e - else: - raise FdcApiError( - f"API error: {response.status_code} - {self._redact(response.text)}" + status = response.status_code + body = self._redact(response.text) + + # api.data.gov, which fronts FDC, answers an invalid key with 403, + # not 401 — so the common case of a wrong key used to arrive as a + # nondescript FdcApiError. + if status in (401, 403): + raise FdcAuthError( + "Authentication failed. Check your API key.", status_code=status + ) from e + if status == 404: + raise FdcResourceNotFoundError( + f"Not found: {endpoint}", status_code=status ) from e + if status == 429: + raise FdcRateLimitError("Rate limit exceeded.", status_code=status) from e + if status == 400: + raise FdcValidationError( + f"The API rejected the request: {body}", status_code=status + ) from e + + raise FdcApiError(f"API error: {status} - {body}", status_code=status) from e except requests.exceptions.Timeout as e: # Must precede RequestException: Timeout is a subclass of it, and a # timeout is worth retrying where a 400 is not. diff --git a/usda_fdc/exceptions.py b/usda_fdc/exceptions.py index ac3dfa8..b56f133 100644 --- a/usda_fdc/exceptions.py +++ b/usda_fdc/exceptions.py @@ -2,12 +2,29 @@ Exceptions for the USDA FDC client. """ +from typing import Optional + + class FdcApiError(Exception): - """Base exception for all FDC API errors.""" - pass + """Base exception for all FDC API errors. + + Args: + message: A description of what went wrong. + status_code: The HTTP status the API replied with, when there was a + reply at all. It is ``None`` for failures that never reached the + API — a refused connection, a timeout, an unreadable body. + """ + + def __init__(self, message: str, status_code: Optional[int] = None): + super().__init__(message) + self.status_code = status_code class FdcAuthError(FdcApiError): - """Exception raised when authentication fails.""" + """Exception raised when authentication fails. + + api.data.gov, which fronts FDC, answers an invalid or missing key with 403 + rather than 401, so both statuses raise this. + """ pass class FdcRateLimitError(FdcApiError): @@ -23,9 +40,17 @@ class FdcTimeoutError(FdcApiError): pass class FdcValidationError(FdcApiError): - """Exception raised when input validation fails.""" + """Exception raised when the API rejects the request as invalid (HTTP 400). + + Typically a parameter outside the range FDC accepts, such as a page_size + above 200. + """ pass class FdcResourceNotFoundError(FdcApiError): - """Exception raised when a requested resource is not found.""" - pass \ No newline at end of file + """Exception raised when a requested resource is not found (HTTP 404). + + A food that does not exist is an ordinary outcome of a lookup, not a + breakdown, so callers deserve to catch it on its own. + """ + pass