Migrating to 0.2.0

0.2.0 corrects four ways the library reported wrong numbers or the wrong exception. Most code needs no changes. The parts that do are listed here, each with what it did before, what it does now, and what to do about it.

The theme is worth stating plainly, because it explains why these are breaking rather than merely new: in each case the old behaviour did not fail, it answered. It returned a plausible number that was wrong, or an exception that said nothing. For nutrition data, quietly wrong is worse than loudly absent, so 0.2.0 prefers None and a specific error over a confident fabrication — and that is a change some callers will see.

Every exception still derives from FdcApiError. A broad except FdcApiError keeps catching everything it used to.

At a glance

What

Before

Now

Missing food (HTTP 404)

FdcApiError

FdcResourceNotFoundError

Invalid API key (HTTP 403)

FdcApiError

FdcAuthError

Rejected request (HTTP 400)

FdcApiError

FdcValidationError

e.status_code

Did not exist

The HTTP status, or None

dri_percent for incomparable units

A wrong number

None

get_dri(..., DriType.UL)

Always None

The upper limit, in grams

Energy in kJ

Could become calories

Kept as energy_kj

API key transport

?api_key= query parameter

X-Api-Key header

1. A missing food now raises its own exception

A food that does not exist is an ordinary outcome of a lookup, not a breakdown, and it used to be indistinguishable from a broken API.

# Before: any failure looked alike
try:
    food = client.get_food(fdc_id)
except FdcApiError:
    ...   # 404? 500? bad key? no way to tell

# Now
from usda_fdc import FdcResourceNotFoundError

try:
    food = client.get_food(fdc_id)
except FdcResourceNotFoundError:
    return None          # it simply is not there
except FdcApiError as e:
    if e.status_code is not None and e.status_code >= 500:
        ...              # worth retrying

What to change: nothing, if you catch FdcApiError. If you branch on the exception’s exact type (type(e) is FdcApiError), a 404, 403 and 400 will no longer land there.

2. status_code exists now — and the documented retry works

The documentation has long shown a retry loop guarded by hasattr(e, 'status_code'). Nothing ever set that attribute, so the guard was always False and the loop could never run. If you copied that example, your retries have silently never happened.

# Delete the hasattr guard; it is dead code that disables the branch
except FdcApiError as e:
    if e.status_code is not None and e.status_code >= 500:
        retry()

status_code is None when the request never reached the API at all — a refused connection, a timeout — which is itself worth branching on.

3. DRI percentages can now be None

dri_percent used to divide a food’s amount by the DRI as a bare number, without regard to units. The shipped data does not use one scale: iron’s RDA is 8 mg, while its UL is 0.045 g. So 100 g of spinach against iron’s upper limit reported 2802.2% of it. The true figure is 2.8%.

Amounts are now converted into the DRI’s unit before the division. Where the two cannot be compared at all — vitamin A in IU against a µg allowance — the answer is None, not a number.

What to change: guard for None before formatting.

# Before: crashes on None, and silently printed nonsense before that
print(f"{value.dri_percent:.1f}%")

# Now
percent = f"{value.dri_percent:.1f}%" if value.dri_percent is not None else "n/a"

4. DriType.UL returns data — in grams

Upper limits used to come back as None for every nutrient, because ul.json ships under a different schema than the loader could read. It works now, but that file expresses everything in grams, where rda.json uses a natural unit per nutrient.

If you call get_dri directly and assumed one unit, you must now check. Use get_dri_value, which hands you the unit with the number:

from usda_fdc.analysis.dri import get_dri_value, DriType, Gender

rda = get_dri_value("iron", DriType.RDA, Gender.MALE, 30)   # 8, "mg"
ul  = get_dri_value("iron", DriType.UL,  Gender.MALE, 30)   # 0.045, "g"

analyze_food already does this conversion for you; only direct get_dri callers are affected.

DriType.AI, EAR and AMDR ship no data. They still return None, but now log a warning once saying so, rather than leaving an empty column with no explanation.

5. Calories no longer come back as kilojoules

FDC lists a food’s energy several times over under the same name — in kcal, and again in kJ, plus Atwater variants. All of them were matched by name and keyed as calories, so whichever came last in the list won. Clarified butter lists 900 kcal and then 3766 kJ, and was analysed as 3766 calories, which the CLI and the HTML report duly printed with a kcal suffix.

calories_per_serving is now always a kcal figure, and the kilojoule value is kept separately under energy_kj:

analysis.calories_per_serving              # 900.0, was 3766.0
analysis.get_nutrient("energy_kj").amount  # 3766.0, if you want it

What to change: nothing, unless you stored calorie figures computed by an earlier version — those may be overstated by roughly 4.2x for affected foods, and are worth recomputing.

6. The API key travels as a header

It used to go in the query string, and requests puts the full URL into its exception messages — so the first connection blip wrote your key into your tracebacks, log files and error tracker. It is now sent as X-Api-Key, and redacted from error messages as a backstop.

FdcClient(api_key=...) and FDC_API_KEY are unchanged. This only matters if you point base_url at a proxy or a recorded fixture that expects the key as a query parameter — those need to read the header instead.

7. Abridged foods keep their nutrients

get_food(fdc_id, format="abridged") used to return a food with zero nutrients, silently: the abridged response inlines each nutrient’s fields rather than nesting them, and only the nested shape was parsed. A food with 18 nutrients came back with none, and anything computing nutrition from it got zeros.

Both shapes are understood now. If you worked around this by avoiding abridged, you no longer need to.