Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,44 @@

All notable changes to the USDA FDC Python Client will be documented in this file.

## [Unreleased]

### Security
- The API key is now sent as an `X-Api-Key` header instead of an `api_key` query
parameter, and is redacted from error messages. `requests` embeds the full URL
in its exception text, so a key in the query string was written into the
caller's tracebacks, log files and error tracker the first time the network
hiccuped:

```
FdcApiError: Request failed: ... Max retries exceeded with url:
/fdc/v1/food/1750340?format=full&api_key=YOUR_REAL_KEY
```

No API change: `FdcClient(api_key=...)` and `FDC_API_KEY` work as before.

### Fixed
- **A food's kilojoules could be reported as its calories.** FDC lists energy
several times per food under the same name — `1008` (kcal), `1062` (the same
energy in kJ), and the Atwater variants `2047`/`2048` (kcal). All of them were
matched on the name alone and keyed as `calories`, so whichever came last in
the list won. Clarified butter (`fdc_id` 171314) lists 900 kcal and then
3766 kJ, and `analyze_food` reported **3766 calories** — which the CLI and the
HTML report then printed with a `kcal` suffix.

Energy is now matched on its nutrient id/number and unit: only kcal rows can
become `calories`, the plain `Energy` row is preferred over the Atwater
variants so the result never depends on list order, and the kJ figure is kept
under `energy_kj` rather than discarded.

- **`format="abridged"` silently returned a food with no nutrients.** An
abridged response inlines each nutrient's fields on the row and carries no
nutrient id, while `format="full"` nests them under a `nutrient` key. Only the
nested shape was parsed, and the rest were dropped without a word: ghee came
back with 0 nutrients instead of 18. Both shapes are now understood, so
anyone reaching for abridged to save bandwidth no longer computes nutrition
from an empty list.

## [0.1.10] - 2026-07-13

### Added
Expand Down
101 changes: 101 additions & 0 deletions tests/unit/test_analysis_energy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""
Tests for how a food's energy is turned into "calories".

FDC reports energy several times per food, under the same name, in different
units and by different derivations:

1008 / 208 Energy kcal
1062 / 268 Energy kJ (the same energy!)
2047 / 957 Energy (Atwater General Factors) kcal
2048 / 958 Energy (Atwater Specific Factors) kcal

Matching those on the name alone made every one of them "calories", so whichever
came last in the list won — and the CLI and HTML report printed the winner with a
kcal suffix regardless of what unit it was actually in.
"""

import pytest

from usda_fdc.models import Food, Nutrient
from usda_fdc.analysis import analyze_food


def _food(*nutrients: Nutrient) -> Food:
return Food(
fdc_id=171314,
description="Butter, Clarified butter (ghee)",
data_type="SR Legacy",
nutrients=list(nutrients),
)


ENERGY_KCAL = Nutrient(id=1008, name="Energy", amount=900.0, unit_name="kcal", nutrient_nbr="208")
ENERGY_KJ = Nutrient(id=1062, name="Energy", amount=3766.0, unit_name="kJ", nutrient_nbr="268")
ATWATER_GENERAL = Nutrient(id=2047, name="Energy (Atwater General Factors)", amount=64.66, unit_name="kcal", nutrient_nbr="957")
ATWATER_SPECIFIC = Nutrient(id=2048, name="Energy (Atwater Specific Factors)", amount=58.20, unit_name="kcal", nutrient_nbr="958")


def test_kilojoules_are_not_reported_as_calories():
"""The regression: real fdc_id 171314 lists 900 kcal and then 3766 kJ, and
was analyzed as 3766 "calories" — a 4.2x overstatement of a food's energy."""
analysis = analyze_food(_food(ENERGY_KCAL, ENERGY_KJ))

assert analysis.calories_per_serving == 900.0


def test_kilojoule_row_does_not_win_by_coming_last():
"""Order is the whole bug: the kJ row overwrote the kcal row simply by being
later in the list."""
analysis = analyze_food(_food(ENERGY_KJ, ENERGY_KCAL))
assert analysis.calories_per_serving == 900.0

analysis = analyze_food(_food(ENERGY_KCAL, ENERGY_KJ))
assert analysis.calories_per_serving == 900.0


def test_kilojoules_are_still_available_under_their_own_id():
"""Keyed separately rather than dropped, so no data is lost."""
analysis = analyze_food(_food(ENERGY_KCAL, ENERGY_KJ))

kj = analysis.get_nutrient("energy_kj")
assert kj is not None
assert kj.amount == 3766.0
assert kj.unit == "kJ"


def test_calories_is_the_kcal_row_even_when_only_kilojoules_and_atwater_exist():
analysis = analyze_food(_food(ENERGY_KJ, ATWATER_SPECIFIC))

assert analysis.calories_per_serving == 58.20


def test_competing_kcal_rows_resolve_the_same_way_regardless_of_order():
"""Real foods (fdc_id 1750340) carry both Atwater rows with different values.
Whichever we prefer, it must not depend on the order FDC happened to send."""
forwards = analyze_food(_food(ATWATER_GENERAL, ATWATER_SPECIFIC))
backwards = analyze_food(_food(ATWATER_SPECIFIC, ATWATER_GENERAL))

assert forwards.calories_per_serving == backwards.calories_per_serving


def test_plain_energy_row_is_preferred_over_atwater_variants():
analysis = analyze_food(_food(ATWATER_GENERAL, ATWATER_SPECIFIC, ENERGY_KCAL))

assert analysis.calories_per_serving == 900.0


def test_energy_is_matched_on_an_abridged_food_which_has_no_nutrient_id():
"""An abridged response carries only the nutrient number, and spells the unit
"KCAL" rather than "kcal"."""
abridged_kcal = Nutrient(id=None, name="Energy", amount=900.0, unit_name="KCAL", nutrient_nbr="208")
abridged_kj = Nutrient(id=None, name="Energy", amount=3770.0, unit_name="kJ", nutrient_nbr="268")

analysis = analyze_food(_food(abridged_kj, abridged_kcal))

assert analysis.calories_per_serving == 900.0


def test_calories_scale_with_serving_size():
analysis = analyze_food(_food(ENERGY_KCAL, ENERGY_KJ), serving_size=50.0)

assert analysis.calories_per_serving == 450.0
55 changes: 55 additions & 0 deletions tests/unit/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,58 @@ def test_timeout_is_distinguishable_from_other_failures():
client._make_request("foods/search")

assert not isinstance(exc.value, FdcTimeoutError)


# ── API key handling ──────────────────────────────────────────────────
# The key used to travel as a query parameter. requests puts the full URL into
# its exception messages, so the first connection blip wrote the caller's key
# into their tracebacks, log files and error tracker.

SECRET = "SUPER_SECRET_KEY_12345"


def test_api_key_is_sent_as_a_header():
client = FdcClient(api_key=SECRET)
assert client.session.headers["X-Api-Key"] == SECRET


def test_api_key_is_not_put_in_the_query_string():
"""The source of the leak: a key in the URL is a key in every log that URL
reaches."""
client = FdcClient(api_key=SECRET)

with patch.object(client.session, "request") as mock_request:
mock_request.return_value = MagicMock(
status_code=200,
json=MagicMock(return_value={}),
raise_for_status=MagicMock(),
)
client._make_request("foods/search")

assert "api_key" not in mock_request.call_args.kwargs["params"]


def test_api_key_does_not_leak_into_a_network_error():
client = FdcClient(api_key=SECRET)

leaky = requests.exceptions.ConnectionError(
f"Max retries exceeded with url: /fdc/v1/food/1?api_key={SECRET}"
)
with patch.object(client.session, "request", side_effect=leaky):
with pytest.raises(FdcApiError) as exc:
client._make_request("food/1")

assert SECRET not in str(exc.value)


def test_api_key_does_not_leak_into_an_http_error_body():
client = FdcClient(api_key=SECRET)

response = MagicMock(status_code=500, text=f"upstream rejected api_key={SECRET}")
response.raise_for_status.side_effect = requests.exceptions.HTTPError("500")

with patch.object(client.session, "request", return_value=response):
with pytest.raises(FdcApiError) as exc:
client._make_request("food/1")

assert SECRET not in str(exc.value)
67 changes: 67 additions & 0 deletions tests/unit/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,3 +139,70 @@ def test_search_result_food_without_gtin_upc_is_none():
})

assert result.gtin_upc is None


# ── Abridged responses ────────────────────────────────────────────────
# FDC describes a nutrient two ways. format=full nests it under a "nutrient"
# key; format=abridged inlines the fields on the row and carries no nutrient id,
# only its number. Parsing only the nested shape meant every nutrient of an
# abridged food was silently dropped: a food with 35 nutrients came back with 0,
# and no error to say so.

ABRIDGED_NUTRIENT = {
"number": "208",
"name": "Energy",
"amount": 900.0,
"unitName": "KCAL",
"derivationCode": "LC",
}

FULL_NUTRIENT = {
"nutrient": {"id": 1008, "name": "Energy", "unitName": "kcal", "number": "208", "rank": 300},
"amount": 900.0,
}


def test_nutrient_parses_the_abridged_inline_shape():
nutrient = Nutrient.from_api_data(ABRIDGED_NUTRIENT)

assert nutrient.name == "Energy"
assert nutrient.amount == 900.0
assert nutrient.unit_name == "KCAL"
assert nutrient.nutrient_nbr == "208"


def test_nutrient_still_parses_the_full_nested_shape():
nutrient = Nutrient.from_api_data(FULL_NUTRIENT)

assert nutrient.id == 1008
assert nutrient.name == "Energy"
assert nutrient.amount == 900.0
assert nutrient.unit_name == "kcal"


def test_abridged_food_keeps_its_nutrients():
"""The regression: a real abridged food (fdc_id 171314) has 18 nutrients and
was parsed into a Food with none, silently. Anyone computing nutrition from
it got zeros."""
food = Food.from_api_data({
"fdcId": 171314,
"description": "Butter, Clarified butter (ghee)",
"dataType": "SR Legacy",
"foodNutrients": [ABRIDGED_NUTRIENT],
})

assert len(food.nutrients) == 1
assert food.nutrients[0].amount == 900.0
assert food.nutrients[0].unit_name == "KCAL"


def test_full_food_still_keeps_its_nutrients():
food = Food.from_api_data({
"fdcId": 171314,
"description": "Butter, Clarified butter (ghee)",
"dataType": "SR Legacy",
"foodNutrients": [FULL_NUTRIENT],
})

assert len(food.nutrients) == 1
assert food.nutrients[0].id == 1008
Loading
Loading